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());