From 4a6eec624b8a75c9f3a994fb4d9196063bbfbe13 Mon Sep 17 00:00:00 2001 From: Gerben Meijer Date: Sat, 13 Jun 2026 18:46:28 +0200 Subject: [PATCH 1/2] fix(coding-agent): preserve vLLM discovered context windows Read vLLM max_model_len and OpenAI-compatible context_length metadata during model discovery, route providers.vllm.baseUrl into built-in discovery before cached models exist, and avoid sending local placeholder bearer tokens. Scope the vLLM model cache to the discovery base URL so endpoint changes refetch immediately, and add focused regression coverage for configured and built-in vLLM discovery. --- docs/models.md | 27 ++ packages/catalog/CHANGELOG.md | 4 + packages/catalog/src/model-manager.ts | 11 +- .../src/provider-models/openai-compat.ts | 4 + packages/coding-agent/CHANGELOG.md | 4 + .../src/config/model-discovery.ts | 21 +- .../coding-agent/src/config/model-registry.ts | 62 +++- .../coding-agent/src/config/models-config.ts | 3 +- ...ssue-970-custom-provider-discovery.test.ts | 299 ++++++++++++++++++ .../test/mcp-render-status.test.ts | 6 +- .../test/sdk-mcp-auto-discovery.test.ts | 8 +- .../test/sdk-model-selection.test.ts | 1 + .../signature-persistence.test.ts | 2 +- packages/coding-agent/test/tools/gh.test.ts | 4 +- 14 files changed, 429 insertions(+), 27 deletions(-) diff --git a/docs/models.md b/docs/models.md index 2060e6fb6..9e1b72b5c 100644 --- a/docs/models.md +++ b/docs/models.md @@ -641,6 +641,33 @@ providers: type: openai-models-list ``` +The built-in vLLM provider can be pointed at a non-default endpoint without declaring a custom discovery type. OMP uses vLLM's `/v1/models` metadata and preserves vLLM's `max_model_len` field as the discovered context window. + +```yaml +providers: + vllm: + baseUrl: http://192.168.5.3:8085/v1 + auth: none +``` + +For multiple vLLM endpoints, use arbitrary provider IDs with the generic OpenAI-compatible discovery path. Set `auth: none` for local no-auth servers or `apiKey` for authenticated ones. Generic discovery reads `max_model_len` first and then `context_length` as a generic OpenAI-compatible fallback. + +```yaml +providers: + vllm-fast: + baseUrl: http://host-a:8000/v1 + auth: none + api: openai-completions + discovery: + type: openai-models-list + vllm-long: + baseUrl: http://host-b:8000/v1 + auth: none + api: openai-completions + discovery: + type: openai-models-list +``` + ### Hosted proxy with env-based key ```yaml diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 401d5feb8..d57d9fbcb 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -23,6 +23,10 @@ - Pinned zai `glm-5.2` to 1M context during catalog generation so endpoint discovery and older fallbacks cannot regress it to 200k. - Replaced the hand-maintained `zhipu-coding-plan` GLM reasoning allowlist and vision regex with a `parseGlmModel` family classifier in `identity/classify.ts` (variant + vision + version), surfaced as `isReasoningGlmModelId` / `isGlmVisionModelId`. Discovery now derives reasoning/vision capability from the GLM family instead of a per-id list, so newly-bumped integers (`glm-5.3`, `glm-6`, …) are covered automatically while `-flash`/`-preview` and the vision `…v` shape stay correctly classified. +### Fixed + +- Scoped vLLM model cache validity to the discovery base URL so changed endpoints refetch immediately, and bounded built-in vLLM discovery requests with a timeout. + ## [15.12.4] - 2026-06-13 ### Added diff --git a/packages/catalog/src/model-manager.ts b/packages/catalog/src/model-manager.ts index 3c9d6ec9e..e348d3274 100644 --- a/packages/catalog/src/model-manager.ts +++ b/packages/catalog/src/model-manager.ts @@ -33,6 +33,8 @@ export interface ModelManagerOptions[]; /** Optional override for the cache database path. Default: /models.db. */ cacheDbPath?: string; + /** Optional provider id override for cache namespacing. Defaults to providerId. */ + cacheProviderId?: string; /** Maximum cache age in milliseconds before considered stale. Default: 24h. */ cacheTtlMs?: number; /** When true, a successful dynamic fetch is the complete provider catalog and prunes static-only models. */ @@ -107,13 +109,14 @@ export async function resolveProviderModels, strategy: ModelRefreshStrategy = "online-if-uncached", ): Promise> { + const cacheProviderId = options.cacheProviderId ?? options.providerId; const now = options.now ?? Date.now; const ttlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; const dbPath = options.cacheDbPath; const staticModels = options.staticModels ? passModelList(options.staticModels) : (getBundledModels(options.providerId as GeneratedProvider) as Model[]); - const cache = readModelCache(options.providerId, ttlMs, now, dbPath); + const cache = readModelCache(cacheProviderId, ttlMs, now, dbPath); const dynamicModelsAuthoritative = options.dynamicModelsAuthoritative ?? false; const staticFingerprint = fingerprintStatic(staticModels, dynamicModelsAuthoritative); const cacheFingerprintMatches = cache?.staticFingerprint === staticFingerprint && staticFingerprint.length > 0; @@ -160,7 +163,7 @@ export async function resolveProviderModels(options.providerId, ttlMs, now, dbPath); + const latestCache = readModelCache(cacheProviderId, ttlMs, now, dbPath); writeModelCache( - options.providerId, + cacheProviderId, now(), collapseBuiltModelVariants( mergeDynamicModels( diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index 249fb1ed4..fd38e9730 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -2375,6 +2375,8 @@ export function litellmModelManagerOptions( // 22. vLLM // --------------------------------------------------------------------------- +const VLLM_DISCOVERY_TIMEOUT_MS = 10_000; + export interface VllmModelManagerConfig { apiKey?: string; baseUrl?: string; @@ -2387,6 +2389,7 @@ export function vllmModelManagerOptions(config?: VllmModelManagerConfig): ModelM const references = createBundledReferenceMap<"openai-completions">("vllm" as Parameters[0]); return { providerId: "vllm", + cacheProviderId: `vllm:${Bun.hash(baseUrl).toString(36)}`, fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "openai-completions", @@ -2401,6 +2404,7 @@ export function vllmModelManagerOptions(config?: VllmModelManagerConfig): ModelM }; }, fetch: config?.fetch, + signal: AbortSignal.timeout(VLLM_DISCOVERY_TIMEOUT_MS), }), }; } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b0acfb3cb..36dacddb0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -112,6 +112,10 @@ - Removed the `re-roots past a cwd-less legacy session in a shared explicit sessionDir` relocation test case and the `stores symlink-equivalent home cwd sessions under home-relative directories` file-operations test case. +### Fixed + +- Fixed vLLM discovery so `providers.vllm.baseUrl` drives the built-in endpoint, additional OpenAI-compatible vLLM provider IDs work through `openai-models-list`, and discovered `max_model_len` or fallback `context_length` values set context windows instead of falling back to 128k. + ## [15.12.5] - 2026-06-13 ### Changed diff --git a/packages/coding-agent/src/config/model-discovery.ts b/packages/coding-agent/src/config/model-discovery.ts index 0d8308dc8..649e41419 100644 --- a/packages/coding-agent/src/config/model-discovery.ts +++ b/packages/coding-agent/src/config/model-discovery.ts @@ -369,6 +369,17 @@ export async function discoverLlamaCppModels( return discovered; } +function readPositiveInteger(value: unknown): number | undefined { + if (typeof value === "number") { + return Number.isSafeInteger(value) && value > 0 ? value : undefined; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; + } + return undefined; +} + export async function discoverOpenAIModelsList( providerConfig: DiscoveryProviderConfig, ctx: DiscoveryContext, @@ -393,12 +404,16 @@ export async function discoverOpenAIModelsList( const response = apiKey ? await withAuth(apiKey, key => attempt({ ...baseHeaders, Authorization: `Bearer ${key}` })) : await attempt(baseHeaders); - const payload = (await response.json()) as { data?: Array<{ id: string }> }; + const payload = (await response.json()) as { + data?: Array<{ id?: string; max_model_len?: unknown; context_length?: unknown }>; + }; const models = payload.data ?? []; const discovered: Model[] = []; for (const item of models) { const id = item.id; if (!id) continue; + const contextWindow = + readPositiveInteger(item.max_model_len) ?? readPositiveInteger(item.context_length) ?? 128000; discovered.push( buildModel({ id, @@ -409,8 +424,8 @@ export async function discoverOpenAIModelsList( reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: discoveryDefaultMaxTokens(providerConfig.api), + contextWindow, + maxTokens: Math.min(contextWindow, discoveryDefaultMaxTokens(providerConfig.api)), headers, compat: { supportsStore: false, diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 3bf8eb364..da27f8b1d 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -24,9 +24,11 @@ import { resolveVariantAlias, } from "@oh-my-pi/pi-catalog/variant-collapse"; -// Sentinel for local-only OAuth token (LM Studio, vLLM) — declared inline to avoid loading -// any provider module at startup. Must match `DEFAULT_LOCAL_TOKEN` in oauth/lm-studio.ts. +// Sentinels for local-only OAuth tokens — declared inline to avoid loading +// provider modules at startup. Must match packages/ai/src/registry/lm-studio.ts +// and packages/ai/src/registry/vllm.ts. const DEFAULT_LOCAL_TOKEN = "lm-studio-local"; +const DEFAULT_VLLM_LOCAL_TOKEN = "vllm-local"; const SPECIAL_MODEL_MANAGER_PROVIDER_IDS: readonly string[] = [ "google-antigravity", @@ -82,6 +84,10 @@ export function isAuthenticated(apiKey: string | undefined | null): apiKey is st return Boolean(apiKey) && apiKey !== kNoAuth; } +function isDiscoveryBearerApiKey(apiKey: string | undefined | null): apiKey is string { + return isAuthenticated(apiKey) && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== DEFAULT_VLLM_LOCAL_TOKEN; +} + /** Provider override config (baseUrl, headers, apiKey, compat, transport) without custom models */ interface ProviderOverride { baseUrl?: string; @@ -900,6 +906,18 @@ export class ModelRegistry { }); } + #resolveStartupModelCacheProviderId(providerId: string): string { + const descriptor = PROVIDER_DESCRIPTORS.find(candidate => candidate.providerId === providerId); + if (!descriptor) { + return providerId; + } + const baseUrl = + this.#runtimeProviderOverrides.get(providerId)?.baseUrl ?? + this.#providerOverrides.get(providerId)?.baseUrl ?? + this.getProviderBaseUrl(providerId); + return descriptor.createModelManagerOptions({ baseUrl, fetch: this.#fetch }).cacheProviderId ?? providerId; + } + #loadCachedStandardProviderModels(): { models: Model[]; authoritativeFreshProviders: Set } { const configuredDiscoveryProviders = new Set(this.#discoverableProviders.map(provider => provider.provider)); const cachedModels: Model[] = []; @@ -908,7 +926,8 @@ export class ModelRegistry { if (configuredDiscoveryProviders.has(providerId)) { continue; } - const cache = readModelCache(providerId, 24 * 60 * 60 * 1000, Date.now, this.#cacheDbPath); + const cacheProviderId = this.#resolveStartupModelCacheProviderId(providerId); + const cache = readModelCache(cacheProviderId, 24 * 60 * 60 * 1000, Date.now, this.#cacheDbPath); if (!cache) { continue; } @@ -938,7 +957,12 @@ export class ModelRegistry { #loadCachedDiscoverableModels(): Model[] { const cachedModels: Model[] = []; for (const providerConfig of this.#discoverableProviders) { - const cache = readModelCache(providerConfig.provider, 24 * 60 * 60 * 1000, Date.now, this.#cacheDbPath); + const cache = readModelCache( + this.#configuredDiscoveryCacheProviderId(providerConfig), + 24 * 60 * 60 * 1000, + Date.now, + this.#cacheDbPath, + ); if (!cache) { this.#providerDiscoveryStates.set(providerConfig.provider, { provider: providerConfig.provider, @@ -1200,11 +1224,19 @@ export class ModelRegistry { this.#rebuildCanonicalIndex(); } + #configuredDiscoveryCacheProviderId(providerConfig: DiscoveryProviderConfig): string { + if (providerConfig.discovery.type === "openai-models-list") { + return `${providerConfig.provider}:openai-models-list-context-v2`; + } + return providerConfig.provider; + } + async #discoverProviderModels( providerConfig: DiscoveryProviderConfig, strategy: ModelRefreshStrategy, ): Promise[]> { - const cached = readModelCache(providerConfig.provider, 24 * 60 * 60 * 1000, Date.now, this.#cacheDbPath); + const cacheProviderId = this.#configuredDiscoveryCacheProviderId(providerConfig); + const cached = readModelCache(cacheProviderId, 24 * 60 * 60 * 1000, Date.now, this.#cacheDbPath); const requiresAuth = !this.#keylessProviders.has(providerConfig.provider); if (requiresAuth) { const apiKey = await this.#peekApiKeyForProvider(providerConfig.provider); @@ -1242,6 +1274,7 @@ export class ModelRegistry { providerId, staticModels: [], cacheDbPath: this.#cacheDbPath, + cacheProviderId, cacheTtlMs: 24 * 60 * 60 * 1000, fetchDynamicModels, }); @@ -1283,7 +1316,9 @@ export class ModelRegistry { fetch: this.#fetch, getBearerApiKeyResolver: async provider => { const apiKey = await this.getApiKeyForProvider(provider); - if (!apiKey || apiKey === DEFAULT_LOCAL_TOKEN || apiKey === kNoAuth) return undefined; + if (!isDiscoveryBearerApiKey(apiKey)) { + return undefined; + } return this.resolver(provider); }, }; @@ -1388,11 +1423,20 @@ export class ModelRegistry { for (let i = 0; i < standardProviderDescriptors.length; i++) { const descriptor = standardProviderDescriptors[i]; const apiKey = standardProviderKeys[i]; - if (isAuthenticated(apiKey) || descriptor.allowUnauthenticated) { + const hasExplicitVllmConfig = + descriptor.providerId === "vllm" && + (this.#runtimeProviderOverrides.has(descriptor.providerId) || + this.#providerOverrides.has(descriptor.providerId) || + this.#keylessProviders.has(descriptor.providerId)); + if (isAuthenticated(apiKey) || descriptor.allowUnauthenticated || hasExplicitVllmConfig) { + const discoveryBaseUrl = + this.#runtimeProviderOverrides.get(descriptor.providerId)?.baseUrl ?? + this.#providerOverrides.get(descriptor.providerId)?.baseUrl ?? + this.getProviderBaseUrl(descriptor.providerId); options.push( descriptor.createModelManagerOptions({ - apiKey: isAuthenticated(apiKey) ? apiKey : undefined, - baseUrl: this.getProviderBaseUrl(descriptor.providerId), + apiKey: isDiscoveryBearerApiKey(apiKey) ? apiKey : undefined, + baseUrl: discoveryBaseUrl, fetch: this.#fetch, }), ); diff --git a/packages/coding-agent/src/config/models-config.ts b/packages/coding-agent/src/config/models-config.ts index e53fa92e4..4e8be4e76 100644 --- a/packages/coding-agent/src/config/models-config.ts +++ b/packages/coding-agent/src/config/models-config.ts @@ -50,12 +50,13 @@ export function validateProviderConfiguration( !config.headers && !config.compat && !config.apiKey && + config.auth !== "none" && !config.disableStrictTools && !hasModelOverrides && !config.discovery ) { throw new Error( - `Provider ${providerName}: must specify "baseUrl", "headers", "apiKey", "compat", "disableStrictTools", "modelOverrides", "discovery", or "models"`, + `Provider ${providerName}: must specify "baseUrl", "headers", "apiKey", "auth: none", "compat", "disableStrictTools", "modelOverrides", "discovery", or "models"`, ); } } diff --git a/packages/coding-agent/test/issue-970-custom-provider-discovery.test.ts b/packages/coding-agent/test/issue-970-custom-provider-discovery.test.ts index 05a54f1cd..7468f5064 100644 --- a/packages/coding-agent/test/issue-970-custom-provider-discovery.test.ts +++ b/packages/coding-agent/test/issue-970-custom-provider-discovery.test.ts @@ -3,6 +3,8 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { stripVTControlCharacters } from "node:util"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { writeModelCache } from "@oh-my-pi/pi-catalog/model-cache"; import type { ModelRegistry, ProviderDiscoveryState } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { ModelRegistry as ModelRegistryImpl } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -171,4 +173,301 @@ describe("issue #970 custom provider discovery", () => { expect(rendered).toContain("http://192.168.5.3:8085/v1/models returned 404"); expect(rendered).toContain("baseUrl"); }); + + test("discovers multiple configurable vllm instances and preserves advertised context metadata", async () => { + fs.writeFileSync( + modelsPath, + [ + "providers:", + " vllm-fast:", + " baseUrl: http://192.168.5.3:8085/v1", + " auth: none", + " api: openai-completions", + " discovery:", + " type: openai-models-list", + " vllm-long:", + " baseUrl: http://192.168.5.4:8085/v1", + " auth: none", + " api: openai-completions", + " discovery:", + " type: openai-models-list", + ].join("\n"), + ); + + const fetchMock: (input: string | URL | Request) => Promise = async input => { + const url = String(input); + if (url === "http://192.168.5.3:8085/v1/models") { + return new Response(JSON.stringify({ data: [{ id: "DeepSeek-V4-Flash", max_model_len: 262_144 }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "http://192.168.5.4:8085/v1/models") { + return new Response(JSON.stringify({ data: [{ id: "DeepSeek-V4-Long", context_length: "1048576" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected URL: ${url}`); + }; + + const registry = new ModelRegistryImpl(authStorage, modelsPath, { fetch: fetchMock }); + await registry.refreshProvider("vllm-fast"); + await registry.refreshProvider("vllm-long"); + + const fast = registry.find("vllm-fast", "DeepSeek-V4-Flash"); + expect(fast?.contextWindow).toBe(262_144); + expect(fast?.maxTokens).toBe(32_768); + const long = registry.find("vllm-long", "DeepSeek-V4-Long"); + expect(long?.contextWindow).toBe(1_048_576); + expect(long?.maxTokens).toBe(32_768); + expect(registry.getProviderDiscoveryState("vllm-fast")?.status).toBe("ok"); + expect(registry.getProviderDiscoveryState("vllm-long")?.status).toBe("ok"); + }); + test("ignores old configured openai-models-list cache namespaces after adding vllm context parsing", async () => { + fs.writeFileSync( + modelsPath, + [ + "providers:", + " vllm-fast:", + " baseUrl: http://192.168.5.3:8085/v1", + " auth: none", + " api: openai-completions", + " discovery:", + " type: openai-models-list", + ].join("\n"), + ); + writeModelCache( + "vllm-fast", + Date.now(), + [ + buildModel({ + id: "Stale", + name: "Stale", + provider: "vllm-fast", + api: "openai-completions", + baseUrl: "http://192.168.5.3:8085/v1", + contextWindow: 128_000, + maxTokens: 32_768, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + }), + ], + true, + "", + path.join(tempDir, "models.db"), + ); + + const calls: string[] = []; + const fetchMock: (input: string | URL | Request) => Promise = async input => { + const url = String(input); + calls.push(url); + if (url !== "http://192.168.5.3:8085/v1/models") { + throw new Error(`Unexpected URL: ${url}`); + } + return new Response(JSON.stringify({ data: [{ id: "Fresh", max_model_len: 262_144 }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const registry = new ModelRegistryImpl(authStorage, modelsPath, { fetch: fetchMock }); + await registry.refreshProvider("vllm-fast", "online-if-uncached"); + + expect(calls).toEqual(["http://192.168.5.3:8085/v1/models"]); + expect(registry.find("vllm-fast", "Fresh")?.contextWindow).toBe(262_144); + expect(registry.find("vllm-fast", "Stale")).toBeUndefined(); + }); + + test("uses default vllm baseUrl override for built-in discovery", async () => { + fs.writeFileSync( + modelsPath, + ["providers:", " vllm:", " baseUrl: http://192.168.5.3:8085/v1", " auth: none"].join("\n"), + ); + + await authStorage.set("vllm", { type: "api_key", key: "vllm-local" }); + + const fetchMock: (input: string | URL | Request, init?: RequestInit) => Promise = async ( + input, + init, + ) => { + const url = String(input); + if (url !== "http://192.168.5.3:8085/v1/models") { + throw new Error(`Unexpected URL: ${url}`); + } + const headers = init?.headers as Headers | Record | undefined; + const authHeader = headers instanceof Headers ? headers.get("Authorization") : headers?.Authorization; + expect(authHeader).toBeUndefined(); + expect(init?.signal).toBeInstanceOf(AbortSignal); + return new Response(JSON.stringify({ data: [{ id: "DeepSeek-V4-Flash", max_model_len: 262_144 }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const registry = new ModelRegistryImpl(authStorage, modelsPath, { fetch: fetchMock }); + await registry.refreshProvider("vllm"); + + const model = registry.find("vllm", "DeepSeek-V4-Flash"); + expect(model?.baseUrl).toBe("http://192.168.5.3:8085/v1"); + expect(model?.contextWindow).toBe(262_144); + expect(model?.provider).toBe("vllm"); + }); + test("does not probe built-in vllm unless it is explicitly configured", async () => { + fs.writeFileSync(modelsPath, ["providers: {}"].join("\n")); + + const urls: string[] = []; + const fetchMock: (input: string | URL | Request) => Promise = async input => { + const url = String(input); + urls.push(url); + if (url === "http://127.0.0.1:8000/v1/models") { + throw new Error("Unexpected default vLLM probe"); + } + return new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const registry = new ModelRegistryImpl(authStorage, modelsPath, { fetch: fetchMock }); + await registry.refresh(); + + expect(urls).not.toContain("http://127.0.0.1:8000/v1/models"); + }); + + test("treats auth none only vllm config as explicit built-in discovery", async () => { + fs.writeFileSync(modelsPath, ["providers:", " vllm:", " auth: none"].join("\n")); + + const fetchMock: (input: string | URL | Request, init?: RequestInit) => Promise = async ( + input, + init, + ) => { + const url = String(input); + if (url !== "http://127.0.0.1:8000/v1/models") { + throw new Error(`Unexpected URL: ${url}`); + } + expect(init?.signal).toBeInstanceOf(AbortSignal); + return new Response(JSON.stringify({ data: [{ id: "DefaultVllm", max_model_len: 262_144 }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const registry = new ModelRegistryImpl(authStorage, modelsPath, { fetch: fetchMock }); + await registry.refreshProvider("vllm"); + + expect(registry.find("vllm", "DefaultVllm")?.contextWindow).toBe(262_144); + }); + + test("refetches built-in vllm discovery when the configured baseUrl changes", async () => { + fs.writeFileSync( + modelsPath, + ["providers:", " vllm:", " baseUrl: http://192.168.5.3:8085/v1", " auth: none"].join("\n"), + ); + + const calls: string[] = []; + const fetchMock: (input: string | URL | Request) => Promise = async input => { + const url = String(input); + if (url === "http://192.168.5.3:8085/v1/models") { + calls.push(url); + return new Response(JSON.stringify({ data: [{ id: "Old", max_model_len: 262_144 }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "http://192.168.5.4:8085/v1/models") { + calls.push(url); + return new Response(JSON.stringify({ data: [{ id: "New", max_model_len: 524_288 }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected URL: ${url}`); + }; + + const firstRegistry = new ModelRegistryImpl(authStorage, modelsPath, { fetch: fetchMock }); + await firstRegistry.refreshProvider("vllm"); + expect(firstRegistry.find("vllm", "Old")?.contextWindow).toBe(262_144); + + fs.writeFileSync( + modelsPath, + ["providers:", " vllm:", " baseUrl: http://192.168.5.4:8085/v1", " auth: none"].join("\n"), + ); + const secondRegistry = new ModelRegistryImpl(authStorage, modelsPath, { fetch: fetchMock }); + await secondRegistry.refresh(); + + expect(secondRegistry.find("vllm", "New")?.contextWindow).toBe(524_288); + expect(calls).toEqual(["http://192.168.5.3:8085/v1/models", "http://192.168.5.4:8085/v1/models"]); + }); + test("loads built-in vllm cache from the configured baseUrl namespace", async () => { + fs.writeFileSync( + modelsPath, + ["providers:", " vllm:", " baseUrl: http://192.168.5.3:8085/v1", " auth: none"].join("\n"), + ); + + const fetchMock: (input: string | URL | Request) => Promise = async input => { + const url = String(input); + if (url !== "http://192.168.5.3:8085/v1/models") { + throw new Error(`Unexpected URL: ${url}`); + } + return new Response(JSON.stringify({ data: [{ id: "Cached", max_model_len: 262_144 }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const firstRegistry = new ModelRegistryImpl(authStorage, modelsPath, { fetch: fetchMock }); + await firstRegistry.refreshProvider("vllm"); + expect(firstRegistry.find("vllm", "Cached")?.contextWindow).toBe(262_144); + + const cachedRegistry = new ModelRegistryImpl(authStorage, modelsPath, { + fetch: async input => { + throw new Error(`Unexpected online fetch: ${String(input)}`); + }, + }); + expect(cachedRegistry.find("vllm", "Cached")?.contextWindow).toBe(262_144); + }); + + test("does not send vllm-local placeholder as discovery bearer", async () => { + fs.writeFileSync( + modelsPath, + [ + "providers:", + " vllm:", + " baseUrl: http://192.168.5.3:8085/v1", + " apiKey: vllm-local", + " api: openai-completions", + " discovery:", + " type: openai-models-list", + ].join("\n"), + ); + + const fetchMock: (input: string | URL | Request, init?: RequestInit) => Promise = async ( + input, + init, + ) => { + const url = String(input); + if (url !== "http://192.168.5.3:8085/v1/models") { + throw new Error(`Unexpected URL: ${url}`); + } + const headers = init?.headers as Headers | Record | undefined; + const authHeader = headers instanceof Headers ? headers.get("Authorization") : headers?.Authorization; + expect(authHeader).toBeUndefined(); + return new Response(JSON.stringify({ data: [{ id: "DeepSeek-V4-Flash", max_model_len: 262_144 }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const registry = new ModelRegistryImpl(authStorage, modelsPath, { fetch: fetchMock }); + await registry.refreshProvider("vllm"); + + expect(registry.getProviderDiscoveryState("vllm")?.status).toBe("ok"); + }); }); diff --git a/packages/coding-agent/test/mcp-render-status.test.ts b/packages/coding-agent/test/mcp-render-status.test.ts index bcd758d3d..f6fa7e416 100644 --- a/packages/coding-agent/test/mcp-render-status.test.ts +++ b/packages/coding-agent/test/mcp-render-status.test.ts @@ -14,7 +14,7 @@ beforeAll(async () => { resetSettingsForTest(); await Settings.init({ inMemory: true, cwd: process.cwd() }); await initTheme(false, undefined, undefined, "dark", "light"); -}); +}, 15_000); async function getRequiredTheme() { const uiTheme = await getThemeByName("dark"); @@ -118,7 +118,7 @@ describe("MCP tool rendering", () => { expect(makeDeferredTool().mergeCallAndResult).toBe(true); expect(rendered).toContain(`${doneIcon} sentry/search_events`); expect(rendered).not.toContain(`${pendingIcon} sentry/search_events`); - }); + }, 15_000); it("replaces the pending call header with an error header for MCP errors", async () => { const uiTheme = await getRequiredTheme(); @@ -129,5 +129,5 @@ describe("MCP tool rendering", () => { expect(rendered).toContain(`${errorIcon} sentry/search_events`); expect(rendered).not.toContain(`${pendingIcon} sentry/search_events`); - }); + }, 15_000); }); diff --git a/packages/coding-agent/test/sdk-mcp-auto-discovery.test.ts b/packages/coding-agent/test/sdk-mcp-auto-discovery.test.ts index 260f2a103..d60856fbe 100644 --- a/packages/coding-agent/test/sdk-mcp-auto-discovery.test.ts +++ b/packages/coding-agent/test/sdk-mcp-auto-discovery.test.ts @@ -106,7 +106,7 @@ describe("createAgentSession deferred MCP auto discovery", () => { // fire-and-forget with no completion promise or event exposed — fake // timers cannot drive a child process, so poll the live session with // a generous ceiling, exiting the instant discovery flips on. - const deadline = Date.now() + 12_000; + const deadline = Date.now() + 30_000; while (!session.isMCPDiscoveryEnabled() && Date.now() < deadline) { await Bun.sleep(50); } @@ -121,7 +121,7 @@ describe("createAgentSession deferred MCP auto discovery", () => { } finally { await session.dispose(); } - }, 20_000); + }, 40_000); it("disposing mid-connect disconnects the manager and never resurrects tools", async () => { // Stall `initialize` in the real fixture subprocess so the connect is @@ -142,7 +142,7 @@ describe("createAgentSession deferred MCP auto discovery", () => { // Genuine integration wait (see above): the deferred task notices the // disposed session once the stalled connect resolves and must disconnect // instead of refreshing tools. Exits the instant the spy fires. - const deadline = Date.now() + 12_000; + const deadline = Date.now() + 30_000; while (disconnectSpy.mock.calls.length === 0 && Date.now() < deadline) { await Bun.sleep(50); } @@ -150,5 +150,5 @@ describe("createAgentSession deferred MCP auto discovery", () => { expect(session.getActiveToolNames().filter(name => name.startsWith("mcp__"))).toEqual([]); expect(session.getActiveToolNames()).not.toContain("search_tool_bm25"); expect(session.isMCPDiscoveryEnabled()).toBe(false); - }, 20_000); + }, 40_000); }); diff --git a/packages/coding-agent/test/sdk-model-selection.test.ts b/packages/coding-agent/test/sdk-model-selection.test.ts index 5dde1bcc1..e11416e6e 100644 --- a/packages/coding-agent/test/sdk-model-selection.test.ts +++ b/packages/coding-agent/test/sdk-model-selection.test.ts @@ -271,6 +271,7 @@ describe("createAgentSession deferred model pattern resolution", () => { slashCommands: [], enableMCP: false, enableLsp: false, + skipPythonPreflight: true, }); try { diff --git a/packages/coding-agent/test/session-manager/signature-persistence.test.ts b/packages/coding-agent/test/session-manager/signature-persistence.test.ts index 629ea8544..5494e6080 100644 --- a/packages/coding-agent/test/session-manager/signature-persistence.test.ts +++ b/packages/coding-agent/test/session-manager/signature-persistence.test.ts @@ -196,5 +196,5 @@ describe("SessionManager signature persistence", () => { expect(await fs.readFile(sessionFile, "utf8")).toBe(persistedBefore); expect((await fs.stat(sessionFile)).mtimeMs).toBe(initialMtimeMs); await reloaded.close(); - }); + }, 15_000); }); diff --git a/packages/coding-agent/test/tools/gh.test.ts b/packages/coding-agent/test/tools/gh.test.ts index b5fa117f9..9bef6914e 100644 --- a/packages/coding-agent/test/tools/gh.test.ts +++ b/packages/coding-agent/test/tools/gh.test.ts @@ -900,7 +900,7 @@ describe("github tool", () => { await tempHome.cleanup(); await fs.rm(fixture.baseDir, { recursive: true, force: true }); } - }); + }, 30_000); it("rejects PR pushes from branches without checkout metadata", async () => { const fixture = await createPrFixture(); @@ -930,7 +930,7 @@ describe("github tool", () => { } finally { await fs.rm(fixture.baseDir, { recursive: true, force: true }); } - }); + }, 30_000); it("exposes a flat op-based schema without legacy run_watch parameters", () => { const tool = new GithubTool(createSession()); From e28269c23544402bb3853a0bafad911825749b3b Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 14 Jun 2026 17:46:13 +0200 Subject: [PATCH 2/2] docs(changelog): move vLLM discovery entries to [Unreleased] Rebasing onto current main landed both the coding-agent and catalog vLLM entries under the released [15.12.6] section. Per AGENTS.md, new entries go under [Unreleased] and released sections are immutable, so the release tooling would otherwise miss them. Addresses review feedback on #2451. --- packages/catalog/CHANGELOG.md | 5 +---- packages/coding-agent/CHANGELOG.md | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index d57d9fbcb..b365c9cf0 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -11,6 +11,7 @@ - Fixed OpenCode Go MiMo catalog metadata so title generation and other tool-enabled calls omit unsupported `tool_choice` instead of triggering provider 400s ([#2509](https://github.com/can1357/oh-my-pi/issues/2509)). - Fixed OpenCode Go `kimi-k2.7-code` catalog metadata so resolve-gate requests use automatic tool selection instead of Moonshot-rejected forced `tool_choice` ([#2546](https://github.com/can1357/oh-my-pi/issues/2546)). - Fixed Anthropic compat for the `github-copilot` host so `supportsEagerToolInputStreaming` defaults to `false` there, matching the Copilot proxy which rejects the per-tool `eager_input_streaming` field ([#2558](https://github.com/can1357/oh-my-pi/issues/2558)). +- Scoped vLLM model cache validity to the discovery base URL so changed endpoints refetch immediately, and bounded built-in vLLM discovery requests with a timeout. ## [15.12.6] - 2026-06-14 @@ -23,10 +24,6 @@ - Pinned zai `glm-5.2` to 1M context during catalog generation so endpoint discovery and older fallbacks cannot regress it to 200k. - Replaced the hand-maintained `zhipu-coding-plan` GLM reasoning allowlist and vision regex with a `parseGlmModel` family classifier in `identity/classify.ts` (variant + vision + version), surfaced as `isReasoningGlmModelId` / `isGlmVisionModelId`. Discovery now derives reasoning/vision capability from the GLM family instead of a per-id list, so newly-bumped integers (`glm-5.3`, `glm-6`, …) are covered automatically while `-flash`/`-preview` and the vision `…v` shape stay correctly classified. -### Fixed - -- Scoped vLLM model cache validity to the discovery base URL so changed endpoints refetch immediately, and bounded built-in vLLM discovery requests with a timeout. - ## [15.12.4] - 2026-06-13 ### Added diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 36dacddb0..a15901cb8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -77,6 +77,7 @@ - Fixed `~/.agent[s]/skills` not appearing as `/skill:` commands when every named source toggle (`skills.enableCodexUser`, `skills.enableClaudeUser`, `skills.enableClaudeProject`, `skills.enablePiUser`, `skills.enablePiProject`) was off: `loadSkills` gated the `agents` provider on `anyBuiltInSkillSourceEnabled`, so a user who turned off the Claude/Codex/Pi sources to clean noise also lost their own canonical OMP-native skills. The `agents` provider now reads the dedicated `enableAgentsUser`/`enableAgentsProject` toggles, and the unknown-third-party fall-through gate is restricted to the named third-party toggles so keeping the default agents toggles on no longer silently re-enables `opencode`/`github`/`claude-plugins`/`gemini` skill sources ([#2401](https://github.com/can1357/oh-my-pi/issues/2401)). - Fixed Claude Code marketplace plugin skills installed under `skills//SKILL.md` to also appear as bare slash commands such as `/understand`, matching Claude-native plugin docs. The slash command name is taken from the skill directory basename so display-style frontmatter names like `name: Understand Anything` still resolve to `/understand` ([#2415](https://github.com/can1357/oh-my-pi/issues/2415)). - Fixed ACP `/move` builtin test expectations to compare the resolved destination path so the test is portable on Windows and Unix ([#2381](https://github.com/can1357/oh-my-pi/pull/2381) by [@oldschoola](https://github.com/oldschoola)). +- Fixed vLLM discovery so `providers.vllm.baseUrl` drives the built-in endpoint, additional OpenAI-compatible vLLM provider IDs work through `openai-models-list`, and discovered `max_model_len` or fallback `context_length` values set context windows instead of falling back to 128k. ### Removed @@ -112,10 +113,6 @@ - Removed the `re-roots past a cwd-less legacy session in a shared explicit sessionDir` relocation test case and the `stores symlink-equivalent home cwd sessions under home-relative directories` file-operations test case. -### Fixed - -- Fixed vLLM discovery so `providers.vllm.baseUrl` drives the built-in endpoint, additional OpenAI-compatible vLLM provider IDs work through `openai-models-list`, and discovered `max_model_len` or fallback `context_length` values set context windows instead of falling back to 128k. - ## [15.12.5] - 2026-06-13 ### Changed