From 07a25a29cdb0ab57c6f25aaa278324276f2aed27 Mon Sep 17 00:00:00 2001 From: snowykr Date: Fri, 31 Jul 2026 21:53:00 +0900 Subject: [PATCH] feat(sdk): add active provider discovery query Expose Q29 providers.list/active with non-secret descriptors, retained query snapshots, and credential-bound discovery eligibility. --- docs/sdk.md | 24 + packages/ai/CHANGELOG.md | 3 + packages/ai/src/auth-storage.ts | 307 +- packages/ai/src/model-manager.ts | 7 +- .../ai/src/providers/openai-completions.ts | 94 +- packages/ai/src/providers/openai-responses.ts | 75 +- .../src/utils/discovery/openai-compatible.ts | 20 +- packages/ai/src/utils/http-inspector.ts | 1 + .../test/auth-storage-codex-selection.test.ts | 11 + .../ai/test/auth-storage-refresh-skew.test.ts | 162 + packages/ai/test/http-inspector.test.ts | 4 +- .../ai/test/openai-completions-compat.test.ts | 79 + .../openai-responses-system-prompt.test.ts | 39 + packages/coding-agent/CHANGELOG.md | 3 + packages/coding-agent/package.json | 2 + .../src/config/model-discovery-manager.ts | 58 +- .../coding-agent/src/config/model-registry.ts | 937 +++++- .../src/config/resolve-config-value.ts | 18 +- .../src/internal-urls/docs-index.generated.ts | 2 +- packages/coding-agent/src/sdk/bus/index.ts | 8 + .../src/sdk/host/query/handlers.ts | 21 +- packages/coding-agent/src/sdk/index.ts | 1 + .../operation-inventory.generated.json | 18 + .../src/sdk/protocol/operation-registry.ts | 7 +- packages/coding-agent/src/sdk/providers.ts | 51 + .../test/manifests/sdk-adapter-parity-v1.json | 96 + .../coding-agent/test/model-registry.test.ts | 2878 ++++++++++++++++- .../test/resolve-config-value.test.ts | 22 + .../test/sdk-adapter-dispositions.test.ts | 2 +- .../coding-agent/test/sdk-host-wiring.test.ts | 16 + .../test/sdk-operation-inventory.test.ts | 2 +- .../test/sdk-operation-matrix.test.ts | 12 +- .../test/sdk-package-exports.test.ts | 9 + .../test/sdk-q29-active-providers.test.ts | 155 + .../test/sdk-query-pagination.test.ts | 183 +- 35 files changed, 5091 insertions(+), 236 deletions(-) create mode 100644 packages/coding-agent/src/sdk/providers.ts create mode 100644 packages/coding-agent/test/resolve-config-value.test.ts create mode 100644 packages/coding-agent/test/sdk-q29-active-providers.test.ts diff --git a/docs/sdk.md b/docs/sdk.md index 08e5fa24ed..2398867603 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -410,6 +410,30 @@ with `requestedProfile` where applicable, whole exact `availableProfiles` entrie that fit the detail budget, and `discoveryQuery: "models.profiles.list"`. The discovery pointer is authoritative when the bounded error cannot include every ID. +### Active provider query (Q29) + +`Q29` / `providers.list/active` pages the providers currently eligible for model +selection through the same authenticated, retained-snapshot envelope as Q10. Each +row is the non-secret DTO `{ provider, connectionKind }`, where `connectionKind` +is `credential` or `credentialless`. + +Provider IDs are returned exactly as they appear in Q10 `model.provider`: existing +mixed-case, spaced, punctuated, and long custom IDs are preserved without aliases +or normalization. Rows are deduplicated and ordered by UTF-8 provider bytes. +Join Q29 to Q10 by exact provider ID; Q10 remains the full configured catalog. + +A credentialed discovery-only provider appears only after fresh discovery proves +the exact model is usable. Static configured models can appear without a network +probe. The query never invokes a model, refreshes credentials, probes a remote +account, or exposes credentials, account metadata, paths, or provider responses. + +Resolver failures are atomic and return +`{ "code": "internal", "message": "Unable to resolve active providers." }`. +They omit a page and restart metadata. An expired continuation follows the shared +cursor contract and returns `error.code: "cursor_expired"` with +`error.restartQuery: true`. Malformed cursor strings return `invalid_cursor`; +cross-query or selector mismatches return `invalid_input`. + ## Answer semantics A remote reply answers a pending ask in every session state: diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 16cc2ef2c7..200b17279e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,6 +5,9 @@ - Added read-only OpenCodex provider discovery with runtime-port resolution, identity-checked health probing, cached `/api/models` catalogs, raw wire model ids, and `/login opencodex` status reprobes without credential persistence. +### Changed + +- OpenAI-compatible discovery and OpenAI Completions/Responses transports now preserve query-bearing endpoint routing, including repeated query parameters. Model resolution records whether a provider discovery result was fetched so consumers can distinguish current discovery evidence from cached data. ### Fixed diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index c688bca981..d65c856c49 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -8,6 +8,7 @@ * - `SqliteAuthCredentialStore`: concrete SQLite-backed implementation */ import { Database, type Statement } from "bun:sqlite"; +import * as crypto from "node:crypto"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { getAgentDbPath, logger } from "@gajae-code/utils"; @@ -456,8 +457,9 @@ export type AuthStorageOptions = { * Resolve a config value (API key, header value, etc.) to an actual value. * - coding-agent injects its resolveConfigValue (supports "!command" syntax via pi-natives) * - Default: checks environment variable first, then treats as literal + * `cacheScope` changes whenever the provider credential configuration changes. */ - configValueResolver?: (config: string) => Promise; + configValueResolver?: (config: string, cacheScope?: string) => Promise; /** * Optional callback fired when AuthStorage automatically disables a * credential because something detected it as no longer usable — today @@ -844,7 +846,9 @@ export class AuthStorage { #usageLogger?: UsageLogger; #fallbackResolver?: (provider: string) => string | undefined; #store: AuthCredentialStore; - #configValueResolver: (config: string) => Promise; + #configValueResolver: (config: string, cacheScope?: string) => Promise; + #resolvedStoredApiKeyValues: Map> = new Map(); + #storedApiKeyResolutionInFlight: Map>> = new Map(); #refreshOAuthCredentialOverride?: AuthStorageOptions["refreshOAuthCredential"]; #fetchUsageReportsOverride?: AuthStorageOptions["fetchUsageReports"]; #sourceLabel?: string; @@ -859,6 +863,9 @@ export class AuthStorage { */ #pendingDisabledEvents: CredentialDisabledEvent[] = []; #generation = 1; + #providerGenerations = new Map(); + #providerConfigurationGenerations = new Map(); + #providerOAuthRefreshGenerations = new Map(); #generationListeners: Set<(generation: number) => void> = new Set(); #oauthRefreshInFlight: Map> = new Map(); #oauthCredentialRefreshInFlight: Map> = new Map(); @@ -915,7 +922,66 @@ export class AuthStorage { getGeneration(): number { return this.#generation; } - + getProviderConfigurationGeneration(provider: string): number { + return this.#getProviderConfigurationGeneration(provider); + } + getProviderOAuthRefreshGeneration(provider: string): number { + return this.#providerOAuthRefreshGenerations.get(resolveOAuthStorageProvider(provider)) ?? 0; + } + #getProviderGeneration(provider: string): number { + return this.#providerGenerations.get(resolveOAuthStorageProvider(provider)) ?? 1; + } + #getProviderConfigurationGeneration(provider: string): number { + return this.#providerConfigurationGenerations.get(resolveOAuthStorageProvider(provider)) ?? 1; + } + getProviderEvidenceGeneration(provider: string, resolvedApiKey?: string): string { + const storageProvider = resolveOAuthStorageProvider(provider); + const evidenceApiKey = resolvedApiKey; + let selectedCredential: ({ index: number } & StoredCredential) | undefined; + try { + selectedCredential = this.#resolveSelectedStoredCredential(provider); + } catch { + return crypto + .createHash("sha256") + .update(`${this.#getProviderGeneration(storageProvider)}\u0000unavailable-selector`) + .digest("hex"); + } + const credentials = selectedCredential + ? [selectedCredential.credential] + : this.#getCredentialsForProvider(provider); + const hasApiKey = credentials.some(credential => credential.type === "api_key"); + const hasUsableOAuth = credentials.some( + credential => + credential.type === "oauth" && Number.isFinite(credential.expires) && credential.expires > Date.now(), + ); + const effectiveEnvKey = + this.#runtimeOverrides.get(provider) || this.#configOverrides.get(provider) || hasApiKey || hasUsableOAuth + ? undefined + : getEnvApiKey(provider); + const storedApiKeyFingerprint = credentials + .filter( + (credential): credential is Extract => credential.type === "api_key", + ) + .map(credential => { + const resolved = this.#resolvedStoredApiKeyValues.get(storageProvider)?.get(credential.key); + return `${credential.key}\u0000${ + credential.key.startsWith("!") + ? (resolved?.fingerprint ?? "") + : (process.env[credential.key] ?? credential.key) + }`; + }) + .join("\u0001"); + const storedOAuthFingerprint = credentials + .filter((credential): credential is Extract => credential.type === "oauth") + .map(credential => `${credential.expires}\u0000${credential.expires > Date.now() ? "usable" : "expired"}`) + .join("\u0001"); + return crypto + .createHash("sha256") + .update( + `${this.#getProviderGeneration(storageProvider)}\u0000${effectiveEnvKey ?? ""}\u0000${storedApiKeyFingerprint}\u0000${storedOAuthFingerprint}\u0000${evidenceApiKey ?? ""}`, + ) + .digest("hex"); + } onGenerationChanged(listener: (generation: number) => void): () => void { this.#generationListeners.add(listener); return () => { @@ -927,8 +993,18 @@ export class AuthStorage { this.#generationListeners.delete(listener); } - #bumpGeneration(reason: string): void { + #bumpGeneration(reason: string, provider?: string): void { this.#generation += 1; + if (provider) { + const storageProvider = resolveOAuthStorageProvider(provider); + this.#providerGenerations.set(storageProvider, this.#getProviderGeneration(storageProvider) + 1); + if (reason !== "stored-api-key-usability") { + this.#providerConfigurationGenerations.set( + storageProvider, + this.#getProviderConfigurationGeneration(storageProvider) + 1, + ); + } + } for (const listener of [...this.#generationListeners]) { try { listener(this.#generation); @@ -977,7 +1053,7 @@ export class AuthStorage { */ setRuntimeApiKey(provider: string, apiKey: string): void { this.#runtimeOverrides.set(provider, apiKey); - this.#bumpGeneration("set-runtime-api-key"); + this.#bumpGeneration("set-runtime-api-key", provider); } /** @@ -988,20 +1064,24 @@ export class AuthStorage { const storageProvider = resolveOAuthStorageProvider(provider); this.#assertCredentialSelectorUsable(storageProvider, selector); this.#runtimeCredentialSelectors.set(storageProvider, selector); + this.#bumpGeneration("set-runtime-credential-selector", provider); } /** * Remove a runtime credential selector. */ removeRuntimeCredentialSelector(provider: string): void { - this.#runtimeCredentialSelectors.delete(resolveOAuthStorageProvider(provider)); + const storageProvider = resolveOAuthStorageProvider(provider); + if (this.#runtimeCredentialSelectors.delete(storageProvider)) { + this.#bumpGeneration("remove-runtime-credential-selector", provider); + } } /** * Remove a runtime API key override. */ removeRuntimeApiKey(provider: string): void { - if (this.#runtimeOverrides.delete(provider)) this.#bumpGeneration("remove-runtime-api-key"); + if (this.#runtimeOverrides.delete(provider)) this.#bumpGeneration("remove-runtime-api-key", provider); } /** Whether a provider is currently authenticated by a runtime API-key override. */ @@ -1021,14 +1101,14 @@ export class AuthStorage { */ setConfigApiKey(provider: string, apiKey: string): void { this.#configOverrides.set(provider, apiKey); - this.#bumpGeneration("set-config-api-key"); + this.#bumpGeneration("set-config-api-key", provider); } /** * Remove a single config-sourced API key override. */ removeConfigApiKey(provider: string): void { - if (this.#configOverrides.delete(provider)) this.#bumpGeneration("remove-config-api-key"); + if (this.#configOverrides.delete(provider)) this.#bumpGeneration("remove-config-api-key", provider); } /** @@ -1036,9 +1116,10 @@ export class AuthStorage { * re-parsing `models.yml` so removed entries actually disappear. */ clearConfigApiKeys(): void { - if (this.#configOverrides.size === 0) return; + const providers = [...this.#configOverrides.keys()]; + if (providers.length === 0) return; this.#configOverrides.clear(); - this.#bumpGeneration("clear-config-api-keys"); + for (const provider of providers) this.#bumpGeneration("clear-config-api-keys", provider); } /** @@ -1098,12 +1179,14 @@ export class AuthStorage { #setStoredCredentials(provider: string, credentials: StoredCredential[]): void { const current = this.#data.get(provider) ?? []; if (storedCredentialArraysEqual(current, credentials)) return; + this.#resolvedStoredApiKeyValues.delete(provider); + this.#storedApiKeyResolutionInFlight.delete(provider); if (credentials.length === 0) { this.#data.delete(provider); } else { this.#data.set(provider, credentials); } - this.#bumpGeneration("credentials"); + this.#bumpGeneration("credentials", provider); } #resolveOAuthDedupeIdentityKey(provider: string, credential: OAuthCredential): string | null { @@ -1356,6 +1439,7 @@ export class AuthStorage { provider: string, type: T, sessionId?: string, + isUsable?: (credential: Extract, index: number) => boolean, ): { credential: Extract; index: number } | undefined { const credentials = this.#getCredentialsForProvider(provider) .map((credential, index) => ({ credential, index })) @@ -1373,7 +1457,10 @@ export class AuthStorage { for (const idx of order) { const candidate = credentials[idx]; - if (!this.#isCredentialBlocked(providerKey, candidate.index)) { + if ( + !this.#isCredentialBlocked(providerKey, candidate.index) && + (isUsable === undefined || isUsable(candidate.credential, candidate.index)) + ) { return candidate; } } @@ -1408,6 +1495,19 @@ export class AuthStorage { const updated = [...entries]; updated[index] = { id: target.id, credential }; this.#setStoredCredentials(provider, updated); + if ( + credential.type === "oauth" && + target.credential.type === "oauth" && + (credential.access !== target.credential.access || + credential.refresh !== target.credential.refresh || + credential.expires !== target.credential.expires) + ) { + const storageProvider = resolveOAuthStorageProvider(provider); + this.#providerOAuthRefreshGenerations.set( + storageProvider, + this.getProviderOAuthRefreshGeneration(storageProvider) + 1, + ); + } } /** @@ -1628,9 +1728,8 @@ export class AuthStorage { * Check if any form of auth is configured for a provider. * Unlike getApiKey(), this doesn't refresh OAuth tokens. */ - hasAuth(provider: string): boolean { - const storageProvider = resolveOAuthStorageProvider(provider); - if (this.#runtimeOverrides.has(storageProvider)) return true; + #hasConfiguredAuth(storageProvider: string): boolean { + if (this.hasRuntimeApiKey(storageProvider)) return true; if (this.#configOverrides.has(storageProvider)) return true; if (this.#getCredentialsForProvider(storageProvider).length > 0) return true; if (getEnvApiKey(storageProvider)) return true; @@ -1638,6 +1737,67 @@ export class AuthStorage { return false; } + hasAuth(provider: string): boolean { + const storageProvider = resolveOAuthStorageProvider(provider); + try { + this.#resolveSelectedStoredCredential(storageProvider); + } catch { + return false; + } + return this.#hasConfiguredAuth(storageProvider); + } + + /** + * Check whether configured auth is currently usable without resolving credentials. + */ + hasUsableAuth(provider: string): boolean { + const storageProvider = resolveOAuthStorageProvider(provider); + try { + const selectedCredential = this.#resolveSelectedStoredCredential(storageProvider); + if (this.hasRuntimeApiKey(storageProvider)) return true; + if (this.#configOverrides.has(storageProvider)) return true; + if (selectedCredential) { + if (selectedCredential.credential.type === "api_key") { + return ( + !this.#isCredentialBlocked( + this.#getProviderTypeKey(storageProvider, selectedCredential.credential.type), + selectedCredential.index, + ) && this.#hasUsableResolvedStoredApiKey(storageProvider, selectedCredential.credential.key) + ); + } + return !this.#isCredentialBlocked( + this.#getProviderTypeKey(storageProvider, selectedCredential.credential.type), + selectedCredential.index, + ); + } + + const credentials = this.#getCredentialsForProvider(storageProvider); + let hasStoredApiKey = false; + let hasSelectableApiKey = false; + let hasUsableApiKey = false; + for (const [index, credential] of credentials.entries()) { + if (credential.type !== "api_key") continue; + hasStoredApiKey = true; + if (this.#isCredentialBlocked(this.#getProviderTypeKey(storageProvider, credential.type), index)) continue; + hasSelectableApiKey = true; + hasUsableApiKey ||= this.#hasUsableResolvedStoredApiKey(storageProvider, credential.key); + } + if (hasStoredApiKey) return hasSelectableApiKey && hasUsableApiKey; + if ( + this.#getCredentialsForProvider(storageProvider).some( + (credential, index) => + credential.type === "oauth" && + !this.#isCredentialBlocked(this.#getProviderTypeKey(storageProvider, credential.type), index), + ) + ) { + return true; + } + } catch { + return false; + } + return Boolean(getEnvApiKey(storageProvider) || this.#fallbackResolver?.(storageProvider)); + } + /** * Check if OAuth credentials are configured for a provider. */ @@ -3504,6 +3664,64 @@ export class AuthStorage { return undefined; } + async #resolveStoredApiKey(provider: string, key: string): Promise { + const storageProvider = resolveOAuthStorageProvider(provider); + const configurationGeneration = this.#getProviderConfigurationGeneration(storageProvider); + const resolutions = + this.#storedApiKeyResolutionInFlight.get(storageProvider) ?? new Map>(); + this.#storedApiKeyResolutionInFlight.set(storageProvider, resolutions); + const existing = resolutions.get(key); + if (existing) return existing; + + const { promise, resolve, reject } = Promise.withResolvers(); + resolutions.set(key, promise); + const publish = (value: string | undefined) => { + if ( + configurationGeneration !== this.#getProviderConfigurationGeneration(storageProvider) || + this.#storedApiKeyResolutionInFlight.get(storageProvider) !== resolutions || + resolutions.get(key) !== promise + ) { + return; + } + const values = + this.#resolvedStoredApiKeyValues.get(storageProvider) ?? + new Map(); + const wasUsable = !values.has(key) || values.get(key)?.usable === true; + const isUsable = (value?.length ?? 0) > 0; + values.set(key, { + fingerprint: value ? crypto.createHash("sha256").update(value).digest("hex") : "", + usable: isUsable, + }); + this.#resolvedStoredApiKeyValues.set(storageProvider, values); + if (key.startsWith("!") && wasUsable !== isUsable) { + this.#bumpGeneration("stored-api-key-usability", storageProvider); + } + }; + void (async () => { + try { + const value = await this.#configValueResolver(key, String(configurationGeneration)); + publish(value); + resolve(value); + } catch (error) { + publish(undefined); + reject(error); + } finally { + if ( + this.#storedApiKeyResolutionInFlight.get(storageProvider) === resolutions && + resolutions.get(key) === promise + ) { + resolutions.delete(key); + if (resolutions.size === 0) this.#storedApiKeyResolutionInFlight.delete(storageProvider); + } + } + })(); + return promise; + } + #hasUsableResolvedStoredApiKey(provider: string, key: string): boolean { + const resolved = this.#resolvedStoredApiKeyValues.get(resolveOAuthStorageProvider(provider))?.get(key); + return key.startsWith("!") ? resolved?.usable === true : true; + } + /** * Peek at API key for a provider without refreshing OAuth tokens. * Used for model discovery where we only need to know if credentials exist @@ -3512,21 +3730,38 @@ export class AuthStorage { */ async peekApiKey(provider: string): Promise { const runtimeKey = this.#runtimeOverrides.get(provider); - if (runtimeKey) { - return runtimeKey; - } + if (runtimeKey) return runtimeKey; const configKey = this.#configOverrides.get(provider); - if (configKey) { - return configKey; + if (configKey) return configKey; + + const selectedCredential = this.#resolveSelectedStoredCredential(provider); + if (selectedCredential?.credential.type === "api_key") { + return this.#resolveStoredApiKey(provider, selectedCredential.credential.key); } - const apiKeySelection = this.#selectCredentialByType(provider, "api_key"); + // Return current OAuth access token only if it is not already expired. + if (selectedCredential?.credential.type === "oauth") { + const expiresAt = selectedCredential.credential.expires; + if (Number.isFinite(expiresAt) && expiresAt > Date.now()) { + if (provider === "github-copilot") { + return JSON.stringify({ + token: selectedCredential.credential.access, + enterpriseUrl: selectedCredential.credential.enterpriseUrl, + }); + } + return selectedCredential.credential.access; + } + return undefined; + } + + const apiKeySelection = this.#selectCredentialByType(provider, "api_key", undefined, credential => + this.#hasUsableResolvedStoredApiKey(provider, credential.key), + ); if (apiKeySelection) { - return this.#configValueResolver(apiKeySelection.credential.key); + return this.#resolveStoredApiKey(provider, apiKeySelection.credential.key); } - // Return current OAuth access token only if it is not already expired. const oauthSelection = this.#selectCredentialByType(provider, "oauth"); if (oauthSelection) { const expiresAt = oauthSelection.credential.expires; @@ -3541,10 +3776,7 @@ export class AuthStorage { } } - const envKey = getEnvApiKey(provider); - if (envKey) return envKey; - - return this.#fallbackResolver?.(provider) ?? undefined; + return getEnvApiKey(provider) || this.#fallbackResolver?.(provider); } /** @@ -3578,14 +3810,16 @@ export class AuthStorage { if (selectedCredential?.credential.type === "api_key") { this.#recordSessionCredential(provider, sessionId, "api_key", selectedCredential.index); - return this.#configValueResolver(selectedCredential.credential.key); + return this.#resolveStoredApiKey(provider, selectedCredential.credential.key); } if (!selectedCredential) { - const apiKeySelection = this.#selectCredentialByType(provider, "api_key", sessionId); + const apiKeySelection = this.#selectCredentialByType(provider, "api_key", sessionId, credential => + this.#hasUsableResolvedStoredApiKey(provider, credential.key), + ); if (apiKeySelection) { this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index); - return this.#configValueResolver(apiKeySelection.credential.key); + return this.#resolveStoredApiKey(provider, apiKeySelection.credential.key); } } @@ -3654,9 +3888,14 @@ export class AuthStorage { } } - async #credentialMatchesApiKey(credential: AuthCredential, apiKey: string): Promise { + async #credentialMatchesApiKey(provider: string, credential: AuthCredential, apiKey: string): Promise { if (credential.type === "api_key") { - return (await this.#configValueResolver(credential.key)) === apiKey; + return ( + (await this.#configValueResolver( + credential.key, + String(this.#getProviderConfigurationGeneration(provider)), + )) === apiKey + ); } if (credential.access === apiKey) return true; return this.#extractStructuredApiKeyToken(apiKey) === credential.access; @@ -3679,7 +3918,7 @@ export class AuthStorage { let matched: { id: number; type: AuthCredential["type"]; index: number } | undefined; for (let index = 0; index < stored.length; index++) { const entry = stored[index]; - if (entry && (await this.#credentialMatchesApiKey(entry.credential, apiKey))) { + if (entry && (await this.#credentialMatchesApiKey(provider, entry.credential, apiKey))) { matched = { id: entry.id, type: entry.credential.type, index }; break; } diff --git a/packages/ai/src/model-manager.ts b/packages/ai/src/model-manager.ts index aed3620b46..d0d4e7d95c 100644 --- a/packages/ai/src/model-manager.ts +++ b/packages/ai/src/model-manager.ts @@ -56,6 +56,8 @@ export interface ModelManagerOptions { models: Model[]; stale: boolean; + /** Whether this resolution successfully fetched dynamic models. */ + fetched: boolean; } /** @@ -147,13 +149,13 @@ export async function resolveProviderModels(cache.models); if (!hasStaticTransportDrift(staticModels, cachedModels)) { - return { models: cachedModels, stale: false }; + return { models: cachedModels, stale: false, fetched: false }; } const repairedModels = mergeDynamicModels(staticModels, cachedModels); if (options.canPublishCache?.() ?? true) { writeModelCache(options.providerId, now(), repairedModels, true, staticFingerprint, dbPath); } - return { models: repairedModels, stale: false }; + return { models: repairedModels, stale: false, fetched: false }; } const [fetchedModelsDevModels, fetchedDynamicModels] = shouldFetchFromNetwork @@ -200,6 +202,7 @@ export async function resolveProviderModels = ( client, copilotPremiumRequests, baseUrl, + requestBaseUrl, + requestQuery, requestHeaders, getCapturedErrorResponse: captureErrorResponse, clearCapturedErrorResponse, @@ -511,7 +576,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( api: output.api, model: model.id, method: "POST", - url: `${baseUrl}/chat/completions`, + url: buildRequestUrl(requestBaseUrl, "chat/completions", requestQuery), headers: requestHeaders, body: params, }; @@ -1029,6 +1094,8 @@ async function createClient( client: OpenAI; copilotPremiumRequests: number | undefined; baseUrl: string | undefined; + requestBaseUrl: string | undefined; + requestQuery: OpenAICompletionsQuery | undefined; requestHeaders: Record; getCapturedErrorResponse: () => CapturedHttpErrorResponse | undefined; clearCapturedErrorResponse: () => void; @@ -1103,19 +1170,29 @@ async function createClient( } // Azure OpenAI requires /deployments/{id}/chat/completions?api-version=YYYY-MM-DD. // The generic openai-completions path adds neither, producing silent 404s. - let azureDefaultQuery: Record | undefined; + let azureQuery: OpenAICompletionsQuery | undefined; if (baseUrl?.includes(".openai.azure.com")) { - const apiVersion = $env.AZURE_OPENAI_API_VERSION || "2024-10-21"; if (!baseUrl.includes("/deployments/")) { - baseUrl = `${baseUrl}/deployments/${model.id}`; + baseUrl = appendUrlPath(baseUrl, `deployments/${model.id}`) ?? baseUrl; } - azureDefaultQuery = { "api-version": apiVersion }; } + const { baseUrl: clientBaseUrl, query: endpointQuery } = splitBaseUrlQuery(baseUrl); + if (baseUrl?.includes(".openai.azure.com") && !hasQueryParameter(endpointQuery, "api-version")) { + azureQuery = new URLSearchParams({ + "api-version": $env.AZURE_OPENAI_API_VERSION || "2024-10-21", + }).toString(); + } + const endpointRequestQuery = endpointQuery; + const requestQuery = + [endpointRequestQuery, azureQuery].filter((query): query is string => query !== undefined).join("&") || undefined; let capturedErrorResponse: CapturedHttpErrorResponse | undefined; const baseFetch = fetchOverride ?? fetch; const wrappedFetch = Object.assign( async (input: string | URL | Request, init?: RequestInit): Promise => { - const response = await baseFetch(input, init); + const response = await baseFetch( + appendQueryToRequest(appendQueryToRequest(input, endpointRequestQuery), azureQuery), + init, + ); if (response.ok) { capturedErrorResponse = undefined; return response; @@ -1171,16 +1248,17 @@ async function createClient( return { client: new OpenAI({ apiKey, - baseURL: baseUrl, + baseURL: clientBaseUrl, dangerouslyAllowBrowser: true, maxRetries: resolveRetryBudget(requestMaxRetries, 5), defaultHeaders: headers, - defaultQuery: azureDefaultQuery, fetch: debugFetch, ...(sdkTimeoutMs !== undefined ? { timeout: sdkTimeoutMs } : {}), }), copilotPremiumRequests, baseUrl, + requestBaseUrl: clientBaseUrl, + requestQuery, requestHeaders: headers, getCapturedErrorResponse: () => capturedErrorResponse, clearCapturedErrorResponse: () => { diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 543a6125a5..2caa3d7e16 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -172,6 +172,62 @@ export function resolveOpenAIProviderBaseUrlForTest( return resolveOpenAIProviderBaseUrl(baseUrl, authCredentialType); } +function appendUrlPath(baseUrl: string | undefined, path: string): string | undefined { + if (!baseUrl) return undefined; + const normalizedPath = path.replace(/^\/+/g, ""); + try { + const parsed = new URL(baseUrl); + parsed.pathname = `${parsed.pathname.replace(/\/+$/g, "")}/${normalizedPath}`; + return parsed.toString(); + } catch { + return `${baseUrl.replace(/\/+$/g, "")}/${normalizedPath}`; + } +} + +type OpenAIResponsesQuery = string; + +function splitBaseUrlQuery(baseUrl: string | undefined): { + baseUrl: string | undefined; + query?: OpenAIResponsesQuery; +} { + if (!baseUrl) return { baseUrl }; + try { + const parsed = new URL(baseUrl); + if (!parsed.search) return { baseUrl }; + const queryStart = baseUrl.indexOf("?"); + const fragmentStart = baseUrl.indexOf("#", queryStart); + const query = baseUrl.slice(queryStart + 1, fragmentStart === -1 ? undefined : fragmentStart); + if (!query) return { baseUrl }; + parsed.search = ""; + return { + baseUrl: parsed.toString(), + query, + }; + } catch { + return { baseUrl }; + } +} + +function appendRawQuery(url: string, query: OpenAIResponsesQuery | undefined): string { + if (!query) return url; + const fragmentStart = url.indexOf("#"); + const beforeFragment = fragmentStart === -1 ? url : url.slice(0, fragmentStart); + const fragment = fragmentStart === -1 ? "" : url.slice(fragmentStart); + return `${beforeFragment}${beforeFragment.includes("?") ? "&" : "?"}${query}${fragment}`; +} + +function buildRequestUrl(baseUrl: string | undefined, path: string, query?: OpenAIResponsesQuery): string | undefined { + const url = appendUrlPath(baseUrl, path); + return url ? appendRawQuery(url, query) : undefined; +} + +function appendQueryToRequest(input: string | URL | Request, query?: OpenAIResponsesQuery): string | URL | Request { + if (!query) return input; + const url = appendRawQuery(input instanceof Request ? input.url : String(input), query); + if (input instanceof Request) return new Request(url, input as unknown as RequestInit); + return url; +} + const OPENAI_RESPONSES_PROGRESS_EVENT_TYPES = new Set([ "response.created", "response.output_item.added", @@ -272,7 +328,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( // Keep request headers and prompt-cache routing on the same session-derived value. const cacheSessionId = getOpenAIResponsesCacheSessionId(options); const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; - const { client, copilotPremiumRequests, baseUrl } = createClient( + const { client, copilotPremiumRequests, baseUrl, requestBaseUrl, requestQuery } = createClient( model, context, apiKey, @@ -296,7 +352,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( api: output.api, model: model.id, method: "POST", - url: `${baseUrl}/responses`, + url: buildRequestUrl(requestBaseUrl, "responses", requestQuery), body: params, }; const openaiStream = await callWithCopilotModelRetry( @@ -439,6 +495,8 @@ function createClient( client: OpenAI; copilotPremiumRequests: number | undefined; baseUrl: string | undefined; + requestBaseUrl: string | undefined; + requestQuery: OpenAIResponsesQuery | undefined; } { if (!apiKey) { apiKey = $credentialEnv("OPENAI_API_KEY"); @@ -489,8 +547,15 @@ function createClient( headers.session_id ??= sessionId; headers["x-client-request-id"] ??= sessionId; } + const { baseUrl: clientBaseUrl, query: endpointQuery } = splitBaseUrlQuery(baseUrl); const baseFetch = fetchOverride ?? fetch; - const boundedFetch = wrapOpenAIFetchForBoundedRateLimits(baseFetch, maxRetryDelayMs); + const queryFetch = Object.assign( + async (input: string | URL | Request, init?: RequestInit): Promise => { + return baseFetch(appendQueryToRequest(input, endpointQuery), init); + }, + baseFetch.preconnect ? { preconnect: baseFetch.preconnect } : {}, + ); + const boundedFetch = wrapOpenAIFetchForBoundedRateLimits(queryFetch, maxRetryDelayMs); const transformedFetch = wrapFetchForOpenAIRequestTransform( boundedFetch, model.requestTransform, @@ -499,7 +564,7 @@ function createClient( return { client: new OpenAI({ apiKey, - baseURL: baseUrl, + baseURL: clientBaseUrl, dangerouslyAllowBrowser: true, maxRetries: resolveRetryBudget(requestMaxRetries, 5), defaultHeaders: headers, @@ -509,6 +574,8 @@ function createClient( }), copilotPremiumRequests, baseUrl, + requestBaseUrl: clientBaseUrl, + requestQuery: endpointQuery, }; } diff --git a/packages/ai/src/utils/discovery/openai-compatible.ts b/packages/ai/src/utils/discovery/openai-compatible.ts index ce49c89fc4..6746f99bee 100644 --- a/packages/ai/src/utils/discovery/openai-compatible.ts +++ b/packages/ai/src/utils/discovery/openai-compatible.ts @@ -125,7 +125,7 @@ export async function fetchOpenAICompatibleModels( const fetchImpl = options.fetch ?? globalThis.fetch; let response: Response; try { - response = await fetchImpl(`${baseUrl}${MODELS_PATH}`, { + response = await fetchImpl(buildModelsUrl(baseUrl), { method: "GET", headers: requestHeaders, signal: options.signal, @@ -193,7 +193,23 @@ function normalizeBaseUrl(baseUrl: string): string { if (!trimmed) { return ""; } - return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed; + try { + const parsed = new URL(trimmed); + parsed.pathname = parsed.pathname.replace(/\/+$/g, ""); + return parsed.toString(); + } catch { + return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed; + } +} + +function buildModelsUrl(baseUrl: string): string { + try { + const parsed = new URL(baseUrl); + parsed.pathname = `${parsed.pathname.replace(/\/+$/g, "")}${MODELS_PATH}`; + return parsed.toString(); + } catch { + return `${baseUrl}${MODELS_PATH}`; + } } function extractModelEntries(payload: unknown): ParsedOpenAICompatibleModelRecord[] | null { diff --git a/packages/ai/src/utils/http-inspector.ts b/packages/ai/src/utils/http-inspector.ts index 4031def4d3..dc41919b12 100644 --- a/packages/ai/src/utils/http-inspector.ts +++ b/packages/ai/src/utils/http-inspector.ts @@ -267,6 +267,7 @@ export function rewriteCopilotError(errorMessage: string, error: unknown, provid function sanitizeDump(dump: RawHttpRequestDump): RawHttpRequestDump { return { ...dump, + url: redactRequestUrl(dump.url), headers: redactHeaders(dump.headers), body: sanitizeDumpBody(dump.body), }; diff --git a/packages/ai/test/auth-storage-codex-selection.test.ts b/packages/ai/test/auth-storage-codex-selection.test.ts index bdf0420e0d..ae98ca454c 100644 --- a/packages/ai/test/auth-storage-codex-selection.test.ts +++ b/packages/ai/test/auth-storage-codex-selection.test.ts @@ -933,4 +933,15 @@ describe("AuthStorage claude oauth ranking", () => { storage.setRuntimeCredentialSelector("anthropic", { kind: "email", value: "missing@example.com" }), ).toThrow("No credential found for anthropic matching email:missing@example.com"); }); + test("returns unavailable evidence for a selector whose credential was removed", async () => { + if (!authStorage) throw new Error("test setup failed"); + const storage = authStorage; + + await storage.set("anthropic", [{ type: "oauth", ...createCredential("acct-a", "a@example.com") }]); + storage.setRuntimeCredentialSelector("anthropic", { kind: "email", value: "a@example.com" }); + await storage.set("anthropic", []); + + expect(() => storage.getProviderEvidenceGeneration("anthropic")).not.toThrow(); + expect(storage.hasUsableAuth("anthropic")).toBe(false); + }); }); diff --git a/packages/ai/test/auth-storage-refresh-skew.test.ts b/packages/ai/test/auth-storage-refresh-skew.test.ts index d3ed0517ad..a23e0206f8 100644 --- a/packages/ai/test/auth-storage-refresh-skew.test.ts +++ b/packages/ai/test/auth-storage-refresh-skew.test.ts @@ -62,10 +62,12 @@ describe("AuthStorage OAuth refresh skew", () => { }, ]); + expect(authStorage.getProviderOAuthRefreshGeneration("unit-oauth-skew")).toBe(0); const apiKey = await authStorage.getApiKey("unit-oauth-skew", "skew-session"); expect(apiKey).toBe("access-after-skew-refresh"); expect(refreshCalls).toBe(1); + expect(authStorage.getProviderOAuthRefreshGeneration("unit-oauth-skew")).toBe(1); const stored = store.listAuthCredentials("unit-oauth-skew"); expect(stored).toHaveLength(1); expect(stored[0]?.credential.type).toBe("oauth"); @@ -125,4 +127,164 @@ describe("AuthStorage OAuth refresh skew", () => { await expect(second).resolves.toBe("access-after-shared-skew-refresh"); expect(refreshCalls).toBe(1); }); + test("coalesces concurrent command-backed credential resolution", async () => { + if (!store) throw new Error("test setup failed"); + + const resolution = Promise.withResolvers(); + let resolverCalls = 0; + const commandStorage = new AuthStorage(store, { + configValueResolver: async config => { + expect(config).toBe("!command-key"); + resolverCalls += 1; + return resolution.promise; + }, + }); + await commandStorage.set("xai", [{ type: "api_key", key: "!command-key" }]); + + const first = commandStorage.getApiKey("xai"); + const second = commandStorage.getApiKey("xai"); + expect(resolverCalls).toBe(1); + + resolution.resolve("resolved-command-key"); + + await expect(first).resolves.toBe("resolved-command-key"); + await expect(second).resolves.toBe("resolved-command-key"); + expect(commandStorage.hasAuth("xai")).toBeTrue(); + }); + test("retires a command-key flight after credentials are replaced", async () => { + if (!store) throw new Error("test setup failed"); + + const firstResolution = Promise.withResolvers(); + const secondResolution = Promise.withResolvers(); + let resolverCalls = 0; + const resolverScopes: string[] = []; + const commandStorage = new AuthStorage(store, { + configValueResolver: async (_config, cacheScope) => { + resolverScopes.push(cacheScope ?? ""); + resolverCalls += 1; + return resolverCalls === 1 ? firstResolution.promise : secondResolution.promise; + }, + }); + await commandStorage.set("xai", [{ type: "api_key", key: "!command-key" }]); + + const first = commandStorage.getApiKey("xai"); + expect(resolverCalls).toBe(1); + + await commandStorage.set("xai", []); + await commandStorage.set("xai", [{ type: "api_key", key: "!command-key" }]); + const second = commandStorage.getApiKey("xai"); + expect(resolverCalls).toBe(2); + expect(resolverScopes[1]).not.toBe(resolverScopes[0]); + + secondResolution.resolve("new-command-key"); + await expect(second).resolves.toBe("new-command-key"); + const currentEvidence = commandStorage.getProviderEvidenceGeneration("xai"); + + firstResolution.resolve("old-command-key"); + await expect(first).resolves.toBe("old-command-key"); + expect(commandStorage.getProviderEvidenceGeneration("xai")).toBe(currentEvidence); + }); + test("matches command credentials with their resolution scope", async () => { + if (!store) throw new Error("test setup failed"); + + const commandStorage = new AuthStorage(store, { + configValueResolver: async (_config, cacheScope) => { + if (cacheScope === undefined) return "wrong-unscoped-key"; + return "current-command-key"; + }, + }); + await commandStorage.set("xai", [{ type: "api_key", key: "!command-key" }]); + + await expect(commandStorage.getApiKey("xai")).resolves.toBe("current-command-key"); + await expect(commandStorage.invalidateCredentialMatching("xai", "current-command-key")).resolves.toBeTrue(); + }); + test("marks a rejected command-backed credential unusable", async () => { + if (!store) throw new Error("test setup failed"); + + let rejectResolution = false; + const commandStorage = new AuthStorage(store, { + configValueResolver: async () => { + if (rejectResolution) throw new Error("command failed"); + return "resolved-command-key"; + }, + }); + await commandStorage.set("xai", [{ type: "api_key", key: "!command-key" }]); + + await expect(commandStorage.getApiKey("xai")).resolves.toBe("resolved-command-key"); + const resolvedEvidence = commandStorage.getProviderEvidenceGeneration("xai"); + rejectResolution = true; + + await expect(commandStorage.getApiKey("xai")).rejects.toThrow("command failed"); + expect(commandStorage.hasUsableAuth("xai")).toBeFalse(); + expect(commandStorage.getProviderEvidenceGeneration("xai")).not.toBe(resolvedEvidence); + }); + test("excludes a transiently blocked OAuth credential from usable auth", async () => { + if (!authStorage) throw new Error("test setup failed"); + + registerOAuthProvider({ + id: "unit-oauth-transient", + name: "Unit OAuth Transient", + sourceId: "auth-storage-refresh-skew-test", + async login() { + return { access: "unused", refresh: "unused", expires: Date.now() + 60 * 60_000 }; + }, + async refreshToken() { + throw new Error("temporary token endpoint failure"); + }, + getApiKey(credentials) { + return credentials.access; + }, + }); + await authStorage.set("unit-oauth-transient", [ + { + type: "oauth", + access: "expiring-access", + refresh: "refresh-access", + expires: Date.now() + 30_000, + }, + ]); + + await expect(authStorage.getApiKey("unit-oauth-transient")).resolves.toBeUndefined(); + expect(authStorage.hasUsableAuth("unit-oauth-transient")).toBeFalse(); + }); + test("does not fall through a blocked API-key selection to OAuth", async () => { + if (!authStorage) throw new Error("test setup failed"); + + await authStorage.set("unit-mixed-auth", [ + { type: "api_key", key: "blocked-api-key" }, + { + type: "oauth", + access: "unblocked-oauth-access", + refresh: "unblocked-oauth-refresh", + expires: Date.now() + 60 * 60_000, + }, + ]); + + await expect(authStorage.getApiKey("unit-mixed-auth", "mixed-session")).resolves.toBe("blocked-api-key"); + await authStorage.markUsageLimitReached("unit-mixed-auth", "mixed-session"); + + expect(authStorage.hasUsableAuth("unit-mixed-auth")).toBeFalse(); + }); + test("prefers a usable API key to an unresolved command key", async () => { + if (!store) throw new Error("test setup failed"); + + let commandCalls = 0; + const commandStorage = new AuthStorage(store, { + configValueResolver: async key => { + if (key === "!empty-command-key") { + commandCalls += 1; + return undefined; + } + return key; + }, + }); + await commandStorage.set("xai", [ + { type: "api_key", key: "!empty-command-key" }, + { type: "api_key", key: "working-api-key" }, + ]); + + await expect(commandStorage.getApiKey("xai")).resolves.toBe("working-api-key"); + expect(commandCalls).toBe(0); + expect(commandStorage.hasUsableAuth("xai")).toBeTrue(); + }); }); diff --git a/packages/ai/test/http-inspector.test.ts b/packages/ai/test/http-inspector.test.ts index 9b53e9ccf9..e8baa7d6df 100644 --- a/packages/ai/test/http-inspector.test.ts +++ b/packages/ai/test/http-inspector.test.ts @@ -64,7 +64,7 @@ describe("HTTP 400 request dump sanitization", () => { api: "anthropic-messages", model: "claude-sonnet-4-6", method: "POST", - url: "https://api.anthropic.com/v1/messages", + url: "https://api.anthropic.com/v1/messages?sig=synthetic-query-secret", headers: { "X-Api-Key": "synthetic-key", }, @@ -103,6 +103,8 @@ describe("HTTP 400 request dump sanitization", () => { expect(saved).not.toContain(syntheticSignature); expect(saved).not.toContain(syntheticRedacted); expect(saved).not.toContain("synthetic-key"); + expect(saved).not.toContain("synthetic-query-secret"); + expect(saved).toContain("https://api.anthropic.com/v1/messages"); expect(saved).toContain("visible text"); expect(saved).toContain("[redacted]"); }); diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts index 00f8e93fba..64c1d0e539 100644 --- a/packages/ai/test/openai-completions-compat.test.ts +++ b/packages/ai/test/openai-completions-compat.test.ts @@ -473,6 +473,85 @@ describe("openai-completions compatibility", () => { expect(assistantObject ? Reflect.get(assistantObject, "reasoning_text") : undefined).toBe("inspect tool output"); expect(assistantObject ? Reflect.get(assistantObject, "reasoning_content") : undefined).toBeUndefined(); }); + it("preserves duplicate endpoint query parameters across SDK requests", async () => { + const model: Model<"openai-completions"> = { + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", + provider: "custom" as Model["provider"], + baseUrl: "https://example.invalid/v1?scope=read&scope=write&sig=a%2fb%20c", + }; + const requests: string[] = []; + let attempt = 0; + const fetch = Object.assign( + async (input: string | URL | Request): Promise => { + requests.push(input instanceof Request ? input.url : String(input)); + attempt++; + if (attempt === 1) { + return new Response("retry", { status: 500, headers: { "retry-after-ms": "0" } }); + } + return createSseResponse(["[DONE]"]); + }, + { preconnect: originalFetch.preconnect }, + ); + + const result = await streamOpenAICompletions(model, baseContext(), { + apiKey: "test-key", + fetch, + requestMaxRetries: 1, + }).result(); + + expect(result.stopReason).toBe("stop"); + expect(requests).toHaveLength(2); + for (const request of requests) { + expect(new URL(request).searchParams.getAll("scope")).toEqual(["read", "write"]); + expect(request).toContain("sig=a%2fb%20c"); + } + }); + it("preserves a percent-encoded explicit Azure API version from the endpoint", async () => { + const model: Model<"openai-completions"> = { + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", + provider: "custom" as Model["provider"], + baseUrl: "https://example.openai.azure.com/openai/v1?api%2Dversion=2025-04-01-preview", + }; + const requests: string[] = []; + const fetch = Object.assign( + async (input: string | URL | Request): Promise => { + requests.push(input instanceof Request ? input.url : String(input)); + return createSseResponse(["[DONE]"]); + }, + { preconnect: originalFetch.preconnect }, + ); + + const result = await streamOpenAICompletions(model, baseContext(), { apiKey: "test-key", fetch }).result(); + + expect(result.stopReason).toBe("stop"); + expect(requests).toHaveLength(1); + expect(new URL(requests[0]!).searchParams.getAll("api-version")).toEqual(["2025-04-01-preview"]); + expect(requests[0]).toContain("?api%2Dversion=2025-04-01-preview"); + }); + it("appends the default Azure API version after endpoint query entries", async () => { + const model: Model<"openai-completions"> = { + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", + provider: "custom" as Model["provider"], + baseUrl: "https://example.openai.azure.com/openai/v1?scope=read&scope=write", + }; + const requests: string[] = []; + const fetch = Object.assign( + async (input: string | URL | Request): Promise => { + requests.push(input instanceof Request ? input.url : String(input)); + return createSseResponse(["[DONE]"]); + }, + { preconnect: originalFetch.preconnect }, + ); + + const result = await streamOpenAICompletions(model, baseContext(), { apiKey: "test-key", fetch }).result(); + + expect(result.stopReason).toBe("stop"); + expect(requests).toHaveLength(1); + expect(new URL(requests[0]!).search).toStartWith("?scope=read&scope=write&api-version="); + }); }); describe("kimi model detection via detectCompat", () => { diff --git a/packages/ai/test/openai-responses-system-prompt.test.ts b/packages/ai/test/openai-responses-system-prompt.test.ts index 0f459da8ed..0b11b8296f 100644 --- a/packages/ai/test/openai-responses-system-prompt.test.ts +++ b/packages/ai/test/openai-responses-system-prompt.test.ts @@ -165,3 +165,42 @@ describe("openai-responses system prompt routing", () => { }); }); }); +describe("openai-responses endpoint query routing", () => { + it("keeps duplicate endpoint query values on path-first client retries", async () => { + const model: Model<"openai-responses"> = { + ...gpt4oMiniModel, + provider: "custom" as Model["provider"], + baseUrl: "https://proxy.example.com/v1?scope=read&scope=write&sig=a%2fb%20c", + }; + const requests: string[] = []; + let attempt = 0; + const fetchMock = Object.assign( + async (input: string | URL | Request): Promise => { + requests.push(input instanceof Request ? input.url : String(input)); + attempt++; + if (attempt === 1) { + return new Response("retry", { status: 500, headers: { "retry-after-ms": "0" } }); + } + return createSseResponse(); + }, + { preconnect: originalFetch.preconnect }, + ); + + const result = await streamOpenAIResponses( + model, + { + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + }, + { apiKey: "test-key", fetch: fetchMock, requestMaxRetries: 1 }, + ).result(); + + expect(result.stopReason).toBe("stop"); + expect(requests).toHaveLength(2); + for (const request of requests) { + const url = new URL(request); + expect(url.pathname).toBe("/v1/responses"); + expect(url.searchParams.getAll("scope")).toEqual(["read", "write"]); + expect(request).toContain("sig=a%2fb%20c"); + } + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b37865335b..20fc3aa4cd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Added + +- Added the paginated public SDK query `providers.list/active` (Q29), returning deterministic, deduplicated `{ provider, connectionKind }` descriptors for locally eligible providers without exposing credentials or performing remote health probes. ### Added diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index a6c50f716b..9b7bd7ef7c 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -131,6 +131,8 @@ }, "./sdk/models": null, "./sdk/models.js": null, + "./sdk/providers": null, + "./sdk/providers.js": null, "./sdk/lifecycle-session": null, "./sdk/lifecycle-session.js": null, "./sdk/startup-capability": null, diff --git a/packages/coding-agent/src/config/model-discovery-manager.ts b/packages/coding-agent/src/config/model-discovery-manager.ts index 3f04b12641..1a9dec3eb1 100644 --- a/packages/coding-agent/src/config/model-discovery-manager.ts +++ b/packages/coding-agent/src/config/model-discovery-manager.ts @@ -37,6 +37,8 @@ export interface DiscoveryMergeInput { models: readonly Model[]; state: ProviderDiscoveryState; warning?: string; + authGeneration?: string; + fetched?: boolean; } export interface ProviderDiscoveryCallbacks { @@ -44,7 +46,9 @@ export interface ProviderDiscoveryCallbacks requiresAuth: (provider: TProvider) => boolean; peekApiKey: (provider: TProvider) => Promise; isAuthenticated: (apiKey: string | undefined) => boolean; - fetchModels: (provider: TProvider) => Promise[]>; + fetchModels: (provider: TProvider, apiKey: string | undefined) => Promise[]>; + getEvidenceGeneration?: (provider: TProvider) => string; + canPublishCache?: (provider: TProvider) => boolean; } /** Owns configured discovery inputs, status, cache lifecycle, and refresh generations. */ @@ -89,6 +93,11 @@ export class ModelDiscoveryManager { const state = this.#states.get(provider); return state === undefined ? undefined : this.#snapshot(state); } + invalidate(provider: string): void { + this.#invalidate(provider); + this.#states.delete(provider); + this.#lastWarnings.delete(provider); + } loadCached(provider: TProvider, cacheDbPath?: string): readonly Model[] { const cache = readModelCache(provider.provider, 24 * 60 * 60 * 1000, Date.now, cacheDbPath); @@ -133,9 +142,21 @@ export class ModelDiscoveryManager { models: models.map(model => model.id), }); + let authGeneration = callbacks.getEvidenceGeneration?.(provider); + let apiKey: string | undefined; if (callbacks.requiresAuth(provider)) { - const apiKey = await callbacks.peekApiKey(provider); + apiKey = await callbacks.peekApiKey(provider); + const resolvedGeneration = callbacks.getEvidenceGeneration?.(provider); if (!this.isCurrent(token)) return this.#stale(token); + if (authGeneration !== resolvedGeneration) { + authGeneration = resolvedGeneration; + // Resolving a command-backed key can update the evidence generation. Keep + // the key resolved for this refresh so round-robin selection cannot switch + // credentials between the request and its published evidence. + if (!this.isCurrent(token) || authGeneration !== callbacks.getEvidenceGeneration?.(provider)) { + return this.#stale(token); + } + } if (!callbacks.isAuthenticated(apiKey)) return unauthenticated(cachedModels); } @@ -145,10 +166,14 @@ export class ModelDiscoveryManager { staticModels: [], cacheDbPath: callbacks.cacheDbPath, cacheTtlMs: 24 * 60 * 60 * 1000, - canPublishCache: () => this.isCurrent(token), + canPublishCache: () => + this.isCurrent(token) && + (callbacks.getEvidenceGeneration === undefined || + callbacks.getEvidenceGeneration(provider) === authGeneration) && + (callbacks.canPublishCache?.(provider) ?? true), fetchDynamicModels: async () => { try { - return await callbacks.fetchModels(provider); + return await callbacks.fetchModels(provider, apiKey); } catch (cause) { error = cause instanceof Error ? cause.message : String(cause); return null; @@ -156,7 +181,11 @@ export class ModelDiscoveryManager { }, }); const result = await manager.refresh(strategy); - if (!this.isCurrent(token)) return this.#stale(token); + if ( + !this.isCurrent(token) || + (callbacks.getEvidenceGeneration !== undefined && callbacks.getEvidenceGeneration(provider) !== authGeneration) + ) + return this.#stale(token); const status: ProviderDiscoveryStatus = error ? result.models.length > 0 ? "cached" @@ -166,7 +195,9 @@ export class ModelDiscoveryManager { ? "cached" : "idle" : result.models.length > 0 - ? "ok" + ? result.stale + ? "cached" + : "ok" : "empty"; const state: ProviderDiscoveryState = { provider: provider.provider, @@ -177,7 +208,7 @@ export class ModelDiscoveryManager { models: result.models.map(model => model.id), error, }; - return this.#complete(token, result.models, state, error); + return this.#complete(token, result.models, state, error, authGeneration, result.fetched); } #complete( @@ -185,6 +216,8 @@ export class ModelDiscoveryManager { models: readonly Model[], state: ProviderDiscoveryState, error?: string, + authGeneration?: string, + fetched?: boolean, ): DiscoveryMergeInput { const current = this.isCurrent(token); if (current) this.#states.set(token.provider, this.#snapshot(state)); @@ -193,7 +226,16 @@ export class ModelDiscoveryManager { if (error) this.#lastWarnings.set(token.provider, error); else this.#lastWarnings.delete(token.provider); } - return this.#snapshot({ provider: token.provider, token, current, models, state, warning }); + return this.#snapshot({ + provider: token.provider, + token, + current, + models, + state, + warning, + authGeneration, + fetched, + }); } #stale(token: DiscoveryRefreshToken): DiscoveryMergeInput { diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index c1780c16eb..2a559d934b 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -37,6 +37,11 @@ import type { OAuthCredentials, OAuthLoginCallbacks } from "@gajae-code/ai/utils import { $pickCredentialEnv, isRecord, logger } from "@gajae-code/utils"; import { parseModelString, resolveProviderModelReference } from "../config/model-resolver"; import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme"; +import { + type ActiveProviderDescriptor, + ActiveProviderResolutionError, + projectActiveProviderDescriptors, +} from "../sdk/providers"; import type { AuthStorage, OAuthCredential } from "../session/auth-storage"; import type { ActiveSearchModelContext, WebSearchMode } from "../web/search/types"; import { type ConfigError, ConfigFile } from "./config-file"; @@ -77,6 +82,20 @@ export type { CanonicalModelIndex, CanonicalModelRecord, CanonicalModelVariant, export { isAuthenticated, kNoAuth }; const MAX_SESSION_CANONICAL_VARIANTS = 64; +function redactDiscoveryUrl(value: string | URL): string { + try { + const url = typeof value === "string" ? new URL(value) : value; + return `${url.origin}${url.pathname}`; + } catch { + return "(invalid URL)"; + } +} +function stripUrlQuery(value: string): string { + const queryStart = value.indexOf("?"); + if (queryStart < 0) return value; + const fragmentStart = value.indexOf("#", queryStart); + return value.slice(0, queryStart) + (fragmentStart < 0 ? "" : value.slice(fragmentStart)); +} function envAvailabilityFingerprint(): string { return Object.entries(process.env) @@ -1014,6 +1033,24 @@ function getConfiguredProviderOrderFromSettings(): string[] { return []; } } +interface ProviderActivityEvidence { + staticModelIds: ReadonlySet; + staticConfigured: boolean; + discoveryConfigured: boolean; + implicitDiscovery: boolean; + descriptorBacked: boolean; + descriptorFresh: boolean; + descriptorModelIds: ReadonlySet; + authGeneration: string; + endpoint: string; +} + +interface ModelManagerDiscoveryOptions { + options: ModelManagerOptions; + authGeneration: string; + apiKey: string | undefined; + endpoint: string; +} /** * Model registry - loads and manages models, resolves API keys via AuthStorage. @@ -1028,6 +1065,21 @@ export class ModelRegistry { #customProviderApiKeys: Map = new Map(); #providerWebSearchModes: Map = new Map(); #keylessProviders: Set = new Set(); + #optionalAuthProviders: Set = new Set(); + #credentiallessAuthFallbackProviders: Map = new Map(); + #providerEvidenceApiKeys: Map = new Map(); + #providerActivity: ReadonlyMap = new Map(); + #configuredProviderIds: ReadonlySet = new Set(); + #configuredDiscoveryProviderIds: ReadonlySet = new Set(); + #descriptorDiscoveryEvidence = new Map< + string, + { fresh: boolean; modelIds: ReadonlySet; authGeneration: string; endpoint: string } + >(); + #descriptorDiscoveryGenerations = new Map(); + #configuredDiscoveryEvidence = new Map< + string, + { authGeneration: string; endpoint: string; modelIds: ReadonlySet } + >(); #discoveryManager = new ModelDiscoveryManager(); #customModelOverlays: CustomModelOverlay[] = []; #providerOverrides: Map = new Map(); @@ -1038,6 +1090,9 @@ export class ModelRegistry { #configError: ConfigError | undefined = undefined; #modelsConfigFile: ConfigFile; #lastStaticLoadMtime: number | null = null; + #lastStaticLoadEnvironmentFingerprint: string | undefined; + #staticModelsLoaded = false; + #lastDisabledProviderKey: string | undefined; #registeredProviderSources: Set = new Set(); #cacheDbPath?: string; #suppressedSelectors: Map = new Map(); @@ -1051,6 +1106,9 @@ export class ModelRegistry { #runtimeProviderSourceByName: Map = new Map(); #rebuildPending: boolean = false; #rebuildSuspended: number = 0; + #configuredApiKeyEnvNames: Set = new Set(); + #optionalAuthPreflightGenerations = new Map(); + #optionalAuthPreflightEpoch = 0; /** * @param authStorage - Auth storage for API key resolution @@ -1120,17 +1178,48 @@ export class ModelRegistry { } } + #getStaticLoadEnvironmentFingerprint(): string { + const providerBaseUrlEnvKeys = new Set( + [ + ...getBundledProviders(), + ...PROVIDER_DESCRIPTORS.map(descriptor => descriptor.providerId), + ...this.#configuredProviderIds, + ].flatMap(getProviderBaseUrlEnvKeys), + ); + return JSON.stringify({ + apiKeyEnv: [...this.#configuredApiKeyEnvNames].sort().map(name => [name, Bun.env[name] ?? ""]), + implicitEndpoints: [ + ["OLLAMA_BASE_URL", Bun.env.OLLAMA_BASE_URL || ""], + ["LLAMA_CPP_BASE_URL", Bun.env.LLAMA_CPP_BASE_URL || ""], + ["LM_STUDIO_BASE_URL", Bun.env.LM_STUDIO_BASE_URL || ""], + ], + providerBaseUrls: [...providerBaseUrlEnvKeys].sort().map(name => [name, Bun.env[name] ?? ""]), + }); + } + #reloadStaticModels(): void { const currentMtime = this.#modelsConfigFile.getMtimeMs(); - if (currentMtime !== null && currentMtime === this.#lastStaticLoadMtime) { - // models.json unchanged since last load; reload + canonical rebuild would be redundant. + const disabledProviderKey = [...getDisabledProviderIdsFromSettings()].sort().join("\u0000"); + const environmentFingerprint = this.#getStaticLoadEnvironmentFingerprint(); + if ( + this.#staticModelsLoaded && + currentMtime === this.#lastStaticLoadMtime && + disabledProviderKey === this.#lastDisabledProviderKey && + environmentFingerprint === this.#lastStaticLoadEnvironmentFingerprint + ) { + // models.json and settings-derived implicit provider state are unchanged. return; } this.#modelsConfigFile.invalidate(); this.#customProviderApiKeys.clear(); this.#providerWebSearchModes.clear(); this.#keylessProviders.clear(); + this.#optionalAuthProviders.clear(); + this.#credentiallessAuthFallbackProviders.clear(); + this.#optionalAuthPreflightEpoch += 1; this.#discoveryManager.reset(); + for (const descriptor of PROVIDER_DESCRIPTORS) this.#clearDescriptorDiscoveryEvidence(descriptor.providerId); + this.#configuredDiscoveryEvidence.clear(); // Drop config-sourced apiKeys from AuthStorage before reload; entries // removed from models.yml must actually disappear from the resolver, not // linger from the previous parse. The post-load setters below repopulate. @@ -1146,6 +1235,7 @@ export class ModelRegistry { this.#modelBindingsApplier.setBindings(undefined); this.#configError = undefined; this.#loadModels(); + this.#lastDisabledProviderKey = disabledProviderKey; } /** @@ -1172,6 +1262,8 @@ export class ModelRegistry { this.#configError = configError; this.#keylessProviders = keylessProviders; this.#discoveryManager.setProviders(discoverableProviders); + this.#configuredProviderIds = new Set(configuredProviders); + this.#configuredDiscoveryProviderIds = new Set(discoverableProviders.map(provider => provider.provider)); this.#customModelOverlays = customModels; this.#providerOverrides = overrides; this.#modelOverrides = modelOverrides; @@ -1192,10 +1284,57 @@ export class ModelRegistry { const combined = this.#mergeCustomModels(withConfigModels, this.#runtimeModelOverlays); const withModelOverrides = this.#applyModelOverrides(combined, this.#modelOverrides); this.#models = applyFinalCodexGpt56ContextCap(this.#applyRuntimeProviderOverrides(withModelOverrides)); + this.#rebuildProviderActivity(); this.#rebuildCanonicalIndex(); this.#lastStaticLoadMtime = this.#modelsConfigFile.getMtimeMs(); + this.#lastStaticLoadEnvironmentFingerprint = this.#getStaticLoadEnvironmentFingerprint(); + this.#staticModelsLoaded = true; } + #rebuildProviderActivity(): void { + const staticModelIds = new Map>(); + const addStaticModel = (provider: string, id: string) => { + const modelIds = staticModelIds.get(provider) ?? new Set(); + modelIds.add(id); + staticModelIds.set(provider, modelIds); + }; + for (const provider of getBundledProviders()) { + for (const model of getBundledModels(provider as Parameters[0]) as Model[]) + addStaticModel(provider, model.id); + } + for (const overlay of [...this.#customModelOverlays, ...this.#runtimeModelOverlays]) + addStaticModel(overlay.provider, overlay.id); + + const runtimeProviderIds = new Set(this.#runtimeProviderSourceByName.keys()); + const providerIds = new Set([ + ...this.#configuredProviderIds, + ...this.#keylessProviders, + ...this.#discoveryManager.providerIds(), + ...this.#descriptorDiscoveryEvidence.keys(), + ...runtimeProviderIds, + ...staticModelIds.keys(), + ]); + const activity = new Map(); + for (const provider of providerIds) { + const discoveryConfigured = this.#configuredDiscoveryProviderIds.has(provider); + const isDiscoveryProvider = this.#discoveryManager.providerIds().has(provider); + const descriptorEvidence = this.#descriptorDiscoveryEvidence.get(provider); + activity.set(provider, { + staticModelIds: new Set(staticModelIds.get(provider) ?? []), + staticConfigured: staticModelIds.has(provider), + discoveryConfigured, + implicitDiscovery: isDiscoveryProvider && !discoveryConfigured, + descriptorBacked: + descriptorEvidence !== undefined || + (!discoveryConfigured && PROVIDER_DESCRIPTORS.some(descriptor => descriptor.providerId === provider)), + descriptorFresh: descriptorEvidence?.fresh ?? false, + descriptorModelIds: new Set(descriptorEvidence?.modelIds ?? []), + authGeneration: descriptorEvidence?.authGeneration ?? "", + endpoint: descriptorEvidence?.endpoint ?? "", + }); + } + this.#providerActivity = activity; + } /** Load built-in models, applying provider-level overrides only. * Per-model overrides are applied later by #applyModelOverrides. */ #loadBuiltInModels(overrides: Map): Model[] { @@ -1338,6 +1477,12 @@ export class ModelRegistry { } #normalizeDiscoverableModels(providerConfig: DiscoveryProviderConfig, models: Model[]): Model[] { + const liveBaseUrl = + providerConfig.discovery.type === "openai-models-list" || providerConfig.discovery.type === "lm-studio" + ? this.#normalizeOpenAIModelsListBaseUrl( + this.#getProviderBaseUrlForDiscovery(providerConfig.provider) ?? providerConfig.baseUrl, + ) + : undefined; return models.map(model => { const normalized = providerConfig.provider === "ollama" && @@ -1345,8 +1490,10 @@ export class ModelRegistry { model.api === "openai-completions" ? ({ ...model, api: "openai-responses" } as Model) : model; + const baseUrl = this.#restoreLiveDiscoveryBaseUrl(normalized.baseUrl, liveBaseUrl); return { ...normalized, + ...(baseUrl !== normalized.baseUrl ? { baseUrl } : {}), requestTransform: providerConfig.requestTransform ? mergeRequestTransform(undefined, providerConfig.requestTransform) : undefined, @@ -1354,6 +1501,21 @@ export class ModelRegistry { }; }); } + #sanitizeDiscoverableModelsForCache(providerConfig: DiscoveryProviderConfig, models: Model[]): Model[] { + return providerConfig.discovery.type === "openai-models-list" || providerConfig.discovery.type === "lm-studio" + ? this.#stripModelBaseUrlQueries(models) + : models; + } + #stripModelBaseUrlQueries(models: readonly Model[]): Model[] { + return models.map(model => (model.baseUrl ? { ...model, baseUrl: stripUrlQuery(model.baseUrl) } : model)); + } + #restoreLiveDiscoveryBaseUrl(modelBaseUrl: string | undefined, liveBaseUrl: string | undefined): string | undefined { + if (!modelBaseUrl || !liveBaseUrl?.includes("?")) return modelBaseUrl; + return this.#normalizeDiscoveryEvidenceEndpoint(stripUrlQuery(modelBaseUrl)) === + this.#normalizeDiscoveryEvidenceEndpoint(stripUrlQuery(liveBaseUrl)) + ? liveBaseUrl + : modelBaseUrl; + } #addImplicitDiscoverableProviders(configuredProviders: Set): void { const disabledProviders = getDisabledProviderIdsFromSettings(); @@ -1365,6 +1527,8 @@ export class ModelRegistry { discovery: { type: "ollama" }, optional: true, }); + // Implicit Ollama auth is optional and may be added after startup. + this.#optionalAuthProviders.add("ollama"); this.#keylessProviders.add("ollama"); } if (!configuredProviders.has("llama.cpp") && !disabledProviders.has("llama.cpp")) { @@ -1375,10 +1539,9 @@ export class ModelRegistry { discovery: { type: "llama.cpp" }, optional: true, }); - // Only mark as keyless if no API key is configured - if (!this.authStorage.hasAuth("llama.cpp")) { - this.#keylessProviders.add("llama.cpp"); - } + // Implicit llama.cpp auth is optional and may be added after startup. + this.#optionalAuthProviders.add("llama.cpp"); + this.#keylessProviders.add("llama.cpp"); } if (!configuredProviders.has("lm-studio") && !disabledProviders.has("lm-studio")) { this.#discoveryManager.addProvider({ @@ -1388,11 +1551,14 @@ export class ModelRegistry { discovery: { type: "lm-studio" }, optional: true, }); + // Implicit LM Studio auth is optional and may be added after startup. + this.#optionalAuthProviders.add("lm-studio"); this.#keylessProviders.add("lm-studio"); } } #loadCustomModels(): CustomModelsResult { + this.#configuredApiKeyEnvNames.clear(); const { value, error, status } = this.#modelsConfigFile.tryLoad(); if (status === "error") { @@ -1428,6 +1594,12 @@ export class ModelRegistry { const configuredProviders = new Set(Object.keys(value.providers ?? {})); for (const [providerName, providerConfig] of providerEntries) { + if (providerConfig.apiKeyEnv) this.#configuredApiKeyEnvNames.add(providerConfig.apiKeyEnv); + if (providerConfig.apiKey) this.#configuredApiKeyEnvNames.add(providerConfig.apiKey); + if (providerConfig.openaiCompat?.apiKeyEnv) + this.#configuredApiKeyEnvNames.add(providerConfig.openaiCompat.apiKeyEnv); + if (providerConfig.openaiCompat?.apiKey) + this.#configuredApiKeyEnvNames.add(providerConfig.openaiCompat.apiKey); if (providerConfig.webSearch) this.#providerWebSearchModes.set(providerName, providerConfig.webSearch); const providerApiKeyConfig = providerConfig.apiKey ?? resolveApiKeyEnvConfig(providerConfig.apiKeyEnv); const localOpenAICompat = providerConfig.openaiCompat; @@ -1467,6 +1639,7 @@ export class ModelRegistry { this.authStorage.setConfigApiKey(providerName, localCompatResolvedKey); } else { keylessProviders.add(providerName); + this.#optionalAuthProviders.add(providerName); } } // Always set overrides when baseUrl/headers/apiKey/authHeader/compat/disableStrictTools/transport are present @@ -1701,15 +1874,145 @@ export class ModelRegistry { ).filter(provider => !disabledProviders.has(provider.provider)); const configuredDiscoveriesPromise = selectedDiscoverableProviders.length === 0 - ? Promise.resolve[]>([]) + ? Promise.resolve( + [] as Array<{ + provider: string; + current: boolean; + models: Model[]; + authGeneration: string; + configurationGeneration: number; + endpoint: string; + fetched: boolean; + }>, + ) : Promise.all( selectedDiscoverableProviders.map(provider => this.#discoverProviderModels(provider, strategy)), - ).then(results => results.flat()); - const [configuredDiscovered, builtInDiscovered] = await Promise.all([ + ); + const [configuredDiscoveryResults, builtInDiscovered] = await Promise.all([ configuredDiscoveriesPromise, this.#discoverBuiltInProviderModels(strategy, providerFilter), ]); - const discovered = [...configuredDiscovered, ...builtInDiscovered]; + const currentConfiguredDiscoveryResults = configuredDiscoveryResults.map(result => { + const providerConfig = selectedDiscoverableProviders.find(provider => provider.provider === result.provider); + const current = + result.current && + providerConfig !== undefined && + (() => { + try { + return ( + result.authGeneration === + this.#getProviderEvidenceGeneration( + result.provider, + this.#providerEvidenceApiKeys.get(result.provider), + ) && + result.endpoint === + this.#normalizeDiscoveryEvidenceEndpoint( + this.#effectiveDiscoveryProviderConfig(providerConfig).baseUrl ?? "", + ) + ); + } catch { + return false; + } + })(); + const invalidatesPublishedState = + result.current && + providerConfig !== undefined && + (() => { + try { + return ( + result.authGeneration !== + this.#getProviderEvidenceGeneration( + result.provider, + this.#providerEvidenceApiKeys.get(result.provider), + ) || + result.configurationGeneration !== + this.authStorage.getProviderConfigurationGeneration(result.provider) || + result.endpoint !== + this.#normalizeDiscoveryEvidenceEndpoint( + this.#effectiveDiscoveryProviderConfig(providerConfig).baseUrl ?? "", + ) + ); + } catch { + return true; + } + })(); + return current + ? { ...result, invalidatesPublishedState } + : { ...result, current: false, models: [], fetched: false, invalidatesPublishedState }; + }); + const currentBuiltInDiscovered = builtInDiscovered.filter(model => { + const evidence = this.#descriptorDiscoveryEvidence.get(model.provider); + const currentEndpoint = this.#normalizeDiscoveryEvidenceEndpoint( + this.#getProviderBaseUrlForDiscovery(model.provider) ?? model.baseUrl ?? "", + ); + const canUseCredentialDerivedXiaomiEndpoint = + model.provider === "xiaomi" && + this.#providerEvidenceApiKeys.get("xiaomi")?.startsWith("tp-") === true && + this.#runtimeProviderOverrides.get("xiaomi")?.baseUrl === undefined && + this.#providerOverrides.get("xiaomi")?.baseUrl === undefined && + resolveProviderBaseUrlFromEnv("xiaomi") === undefined; + try { + return ( + evidence !== undefined && + evidence.authGeneration === + this.#getProviderEvidenceGeneration( + model.provider, + this.#providerEvidenceApiKeys.get(model.provider), + ) && + (evidence.endpoint === currentEndpoint || + (canUseCredentialDerivedXiaomiEndpoint && + evidence.endpoint === this.#normalizeDiscoveryEvidenceEndpoint(model.baseUrl ?? ""))) && + evidence.modelIds.has(model.id) + ); + } catch { + return false; + } + }); + const configuredDiscoveryEvidence = new Map( + currentConfiguredDiscoveryResults + .filter(result => result.current) + .map(result => [ + result.provider, + { + authGeneration: result.authGeneration, + endpoint: result.endpoint, + modelIds: new Set(result.models.map(model => model.id)), + }, + ]), + ); + const configuredDiscoveries = new Map(currentConfiguredDiscoveryResults.map(result => [result.provider, result])); + const configuredDiscovered = currentConfiguredDiscoveryResults.flatMap(result => result.models); + const discovered = [...configuredDiscovered, ...currentBuiltInDiscovered]; + for (const provider of selectedDiscoverableProviders) { + const evidence = configuredDiscoveryEvidence.get(provider.provider); + const discovery = configuredDiscoveries.get(provider.provider); + const state = this.#discoveryManager.getState(provider.provider); + if (!discovery?.current) { + if (discovery?.invalidatesPublishedState) this.#discoveryManager.invalidate(provider.provider); + continue; + } + const currentAuthGeneration = discovery.authGeneration; + const currentEndpoint = this.#normalizeDiscoveryEvidenceEndpoint( + this.#effectiveDiscoveryProviderConfig(provider).baseUrl ?? "", + ); + if ( + evidence !== undefined && + state?.status === "ok" && + discovery.fetched && + currentAuthGeneration === evidence.authGeneration && + currentEndpoint === evidence.endpoint + ) { + this.#configuredDiscoveryEvidence.set(provider.provider, evidence); + } else if ( + (state?.status !== "cached" && !(state?.status === "ok" && !discovery.fetched)) || + state.error !== undefined || + this.#configuredDiscoveryEvidence.get(provider.provider)?.authGeneration !== currentAuthGeneration || + this.#configuredDiscoveryEvidence.get(provider.provider)?.endpoint !== currentEndpoint + ) { + this.#configuredDiscoveryEvidence.delete(provider.provider); + } + } + this.#rebuildProviderActivity(); if (discovered.length === 0) { return; } @@ -1734,40 +2037,198 @@ export class ModelRegistry { async #discoverProviderModels( providerConfig: DiscoveryProviderConfig, strategy: ModelRefreshStrategy, - ): Promise[]> { - const mergeInput = await this.#discoveryManager.discover(providerConfig, strategy, { + ): Promise<{ + provider: string; + current: boolean; + models: Model[]; + authGeneration: string; + configurationGeneration: number; + endpoint: string; + fetched: boolean; + }> { + const provider = providerConfig.provider; + const preflightEpoch = this.#optionalAuthPreflightEpoch; + const preflightGeneration = (this.#optionalAuthPreflightGenerations.get(provider) ?? 0) + 1; + this.#optionalAuthPreflightGenerations.set(provider, preflightGeneration); + const isCurrentPreflight = () => + this.#optionalAuthPreflightEpoch === preflightEpoch && + this.#optionalAuthPreflightGenerations.get(provider) === preflightGeneration; + let preflightApiKey: string | undefined; + let preflightFailed = false; + let preflightStale = false; + let preflightCompleted = false; + const optionalAuth = this.#optionalAuthProviders.has(provider); + const shouldPreflightAuth = optionalAuth + ? this.authStorage.has(provider) || this.authStorage.hasAuth(provider) + : !this.#isCredentiallessProvider(provider); + let preflightAuthConfigurationGeneration = this.authStorage.getProviderConfigurationGeneration(provider); + let preflightOAuthRefreshGeneration = this.authStorage.getProviderOAuthRefreshGeneration(provider); + if (shouldPreflightAuth) { + if (optionalAuth && isCurrentPreflight()) this.#credentiallessAuthFallbackProviders.delete(provider); + let apiKey: string | undefined; + try { + apiKey = await this.#peekApiKeyForProvider(provider, { + ignoreCredentiallessFallback: optionalAuth, + refreshOAuth: true, + baseUrl: providerConfig.baseUrl, + }); + const currentAuthConfigurationGeneration = this.authStorage.getProviderConfigurationGeneration(provider); + if (preflightAuthConfigurationGeneration !== currentAuthConfigurationGeneration) { + const currentOAuthRefreshGeneration = this.authStorage.getProviderOAuthRefreshGeneration(provider); + if ( + currentOAuthRefreshGeneration === preflightOAuthRefreshGeneration || + currentAuthConfigurationGeneration - preflightAuthConfigurationGeneration !== + currentOAuthRefreshGeneration - preflightOAuthRefreshGeneration + ) { + preflightStale = true; + } else { + preflightAuthConfigurationGeneration = currentAuthConfigurationGeneration; + preflightOAuthRefreshGeneration = currentOAuthRefreshGeneration; + } + } + } catch (error) { + preflightFailed = true; + logger.warn("model discovery credential preflight failed", { + provider, + error: error instanceof Error ? error.message : String(error), + }); + } + preflightApiKey = apiKey; + preflightCompleted = true; + if (!preflightFailed && optionalAuth && isCurrentPreflight()) { + this.#providerEvidenceApiKeys.set(provider, apiKey); + const authGeneration = this.authStorage.getProviderEvidenceGeneration(provider, apiKey); + if (apiKey === undefined) this.#credentiallessAuthFallbackProviders.set(provider, authGeneration); + else this.#credentiallessAuthFallbackProviders.delete(provider); + } + } + const effectiveProviderConfig = this.#effectiveDiscoveryProviderConfig(providerConfig); + const endpoint = this.#normalizeDiscoveryEvidenceEndpoint(effectiveProviderConfig.baseUrl ?? ""); + if (!isCurrentPreflight() || preflightStale) { + return { + provider: effectiveProviderConfig.provider, + current: false, + models: [], + authGeneration: this.#getProviderEvidenceGeneration(provider, preflightApiKey), + configurationGeneration: preflightAuthConfigurationGeneration, + endpoint, + fetched: false, + }; + } + if (optionalAuth && preflightFailed) { + return { + provider: effectiveProviderConfig.provider, + current: false, + models: [], + authGeneration: this.#getProviderEvidenceGeneration(provider, preflightApiKey), + configurationGeneration: preflightAuthConfigurationGeneration, + endpoint, + fetched: false, + }; + } + const authGenerationBeforeDiscovery = this.#getProviderEvidenceGeneration(provider, preflightApiKey); + const isCurrentEndpoint = () => + endpoint === + this.#normalizeDiscoveryEvidenceEndpoint(this.#effectiveDiscoveryProviderConfig(providerConfig).baseUrl ?? ""); + const evidence = this.#configuredDiscoveryEvidence.get(provider); + const refreshStrategy = + strategy === "online-if-uncached" && + evidence !== undefined && + (evidence.authGeneration !== authGenerationBeforeDiscovery || evidence.endpoint !== endpoint) + ? "online" + : strategy; + const mergeInput = await this.#discoveryManager.discover(effectiveProviderConfig, refreshStrategy, { cacheDbPath: this.#cacheDbPath, - requiresAuth: provider => !this.#keylessProviders.has(provider.provider), - peekApiKey: provider => this.#peekApiKeyForProvider(provider.provider), + requiresAuth: provider => !this.#isCredentiallessProvider(provider.provider), + peekApiKey: async provider => + preflightCompleted + ? preflightApiKey + : this.#peekApiKeyForProvider(provider.provider, { + refreshOAuth: true, + baseUrl: provider.baseUrl, + }), isAuthenticated, - fetchModels: provider => this.#discoverModelsByProviderType(provider), + fetchModels: async (provider, apiKey) => + this.#sanitizeDiscoverableModelsForCache( + provider, + await this.#discoverModelsByProviderType(provider, apiKey), + ), + getEvidenceGeneration: provider => this.#getProviderEvidenceGeneration(provider.provider, preflightApiKey), + canPublishCache: () => isCurrentEndpoint(), }); - if (!mergeInput.current) return []; + const authGeneration = + mergeInput.authGeneration ?? + this.#getProviderEvidenceGeneration(effectiveProviderConfig.provider, preflightApiKey); + const current = + mergeInput.current && + authGeneration === this.#getProviderEvidenceGeneration(effectiveProviderConfig.provider, preflightApiKey) && + isCurrentEndpoint(); + if (!current) { + return { + provider: effectiveProviderConfig.provider, + current: false, + models: [], + authGeneration, + configurationGeneration: preflightAuthConfigurationGeneration, + endpoint, + fetched: false, + }; + } if (mergeInput.warning) { logger.warn("model discovery failed for provider", { - provider: providerConfig.provider, - url: providerConfig.baseUrl, + provider: effectiveProviderConfig.provider, + url: redactDiscoveryUrl(effectiveProviderConfig.baseUrl ?? ""), error: mergeInput.warning, }); } - return this.#applyProviderModelOverrides( - providerConfig.provider, - this.#normalizeDiscoverableModels( - providerConfig, - this.#applyProviderCompat(providerConfig.compat, [...mergeInput.models]), + this.#providerEvidenceApiKeys.set(effectiveProviderConfig.provider, preflightApiKey); + return { + provider: effectiveProviderConfig.provider, + current: true, + authGeneration, + configurationGeneration: preflightAuthConfigurationGeneration, + endpoint, + fetched: mergeInput.fetched ?? false, + models: this.#applyProviderModelOverrides( + effectiveProviderConfig.provider, + this.#normalizeDiscoverableModels( + effectiveProviderConfig, + this.#applyProviderCompat(effectiveProviderConfig.compat, [...mergeInput.models]), + ), ), - ); + }; + } + #effectiveDiscoveryProviderConfig(providerConfig: DiscoveryProviderConfig): DiscoveryProviderConfig { + const override = this.#runtimeProviderOverrides.get(providerConfig.provider); + const baseUrl = this.#getProviderBaseUrlForDiscovery(providerConfig.provider) ?? providerConfig.baseUrl; + const effectiveBaseUrl = + providerConfig.discovery.type === "ollama" + ? this.#normalizeOllamaBaseUrl(baseUrl) + : providerConfig.discovery.type === "llama.cpp" + ? this.#normalizeLlamaCppBaseUrl(baseUrl) + : this.#normalizeOpenAIModelsListBaseUrl(baseUrl); + return { + ...providerConfig, + baseUrl: effectiveBaseUrl, + headers: override?.headers ? { ...providerConfig.headers, ...override.headers } : providerConfig.headers, + compat: override?.compat ? mergeCompat(providerConfig.compat, override.compat) : providerConfig.compat, + requestTransform: mergeRequestTransform(providerConfig.requestTransform, override?.requestTransform), + cacheRetention: override?.cacheRetention ?? providerConfig.cacheRetention, + }; } - #discoverModelsByProviderType(providerConfig: DiscoveryProviderConfig): Promise[]> { + #discoverModelsByProviderType( + providerConfig: DiscoveryProviderConfig, + apiKey: string | undefined, + ): Promise[]> { switch (providerConfig.discovery.type) { case "ollama": - return this.#discoverOllamaModels(providerConfig); + return this.#discoverOllamaModels(providerConfig, apiKey); case "llama.cpp": - return this.#discoverLlamaCppModels(providerConfig); + return this.#discoverLlamaCppModels(providerConfig, apiKey); case "lm-studio": case "openai-models-list": - return this.#discoverOpenAIModelsList(providerConfig); + return this.#discoverOpenAIModelsList(providerConfig, apiKey); } } @@ -1777,22 +2238,21 @@ export class ModelRegistry { ): Promise[]> { // Skip providers already handled by configured discovery (e.g. user-configured ollama with discovery.type) const configuredDiscoveryProviders = new Set(this.#discoveryManager.providers.map(p => p.provider)); - const managerOptions = (await this.#collectBuiltInModelManagerOptions()).filter(opts => { - if (configuredDiscoveryProviders.has(opts.providerId)) { - return false; - } - return providerFilter ? providerFilter.has(opts.providerId) : true; - }); + const managerOptions = (await this.#collectBuiltInModelManagerOptions(configuredDiscoveryProviders)).filter( + entry => (providerFilter ? providerFilter.has(entry.options.providerId) : true), + ); if (managerOptions.length === 0) { return []; } const discoveries = await Promise.all( - managerOptions.map(options => this.#discoverWithModelManager(options, strategy)), + managerOptions.map(entry => this.#discoverWithModelManager(entry, strategy)), ); return discoveries.flat(); } - async #collectBuiltInModelManagerOptions(): Promise[]> { + async #collectBuiltInModelManagerOptions( + excludedProviderIds: ReadonlySet = new Set(), + ): Promise { const specialProviderDescriptors: Array<{ providerId: string; resolveKey: (value: string | undefined) => string | undefined; @@ -1830,55 +2290,180 @@ export class ModelRegistry { ]; const disabledProviders = getDisabledProviderIdsFromSettings(); const standardProviderDescriptors = PROVIDER_DESCRIPTORS.filter( - descriptor => !disabledProviders.has(descriptor.providerId), + descriptor => !disabledProviders.has(descriptor.providerId) && !excludedProviderIds.has(descriptor.providerId), ); const enabledSpecialProviderDescriptors = specialProviderDescriptors.filter( - descriptor => !disabledProviders.has(descriptor.providerId), + descriptor => !disabledProviders.has(descriptor.providerId) && !excludedProviderIds.has(descriptor.providerId), ); + for (const descriptor of standardProviderDescriptors) { + if (!descriptor.allowUnauthenticated) continue; + this.#keylessProviders.add(descriptor.providerId); + this.#optionalAuthProviders.add(descriptor.providerId); + } // Use peekApiKey to avoid OAuth token refresh during discovery. // The token is only needed if the dynamic fetch fires (cache miss), // and failures there are handled gracefully. - const peekKey = (descriptor: { providerId: string }) => this.#peekApiKeyForProvider(descriptor.providerId); - const [standardProviderKeys, specialKeys] = await Promise.all([ + const peekKey = async (descriptor: { providerId: string }) => { + const configurationGeneration = this.authStorage.getProviderConfigurationGeneration(descriptor.providerId); + const apiKey = await this.#peekApiKeyForProvider(descriptor.providerId); + if (configurationGeneration !== this.authStorage.getProviderConfigurationGeneration(descriptor.providerId)) { + return { apiKey: undefined, authGeneration: undefined }; + } + return { + apiKey, + authGeneration: this.#getProviderEvidenceGeneration(descriptor.providerId, apiKey), + }; + }; + const [standardProviderCredentials, specialProviderCredentials] = await Promise.all([ Promise.all(standardProviderDescriptors.map(peekKey)), Promise.all(enabledSpecialProviderDescriptors.map(peekKey)), ]); - const options: ModelManagerOptions[] = []; + const options: ModelManagerDiscoveryOptions[] = []; for (let i = 0; i < standardProviderDescriptors.length; i++) { const descriptor = standardProviderDescriptors[i]; - const apiKey = standardProviderKeys[i]; - if (isAuthenticated(apiKey) || descriptor.allowUnauthenticated) { - options.push( - descriptor.createModelManagerOptions({ + const { apiKey, authGeneration } = standardProviderCredentials[i]; + if ( + authGeneration !== undefined && + authGeneration === this.#getProviderEvidenceGeneration(descriptor.providerId, apiKey) && + (isAuthenticated(apiKey) || descriptor.allowUnauthenticated) + ) { + const baseUrl = this.#getProviderBaseUrlForDiscovery(descriptor.providerId); + options.push({ + options: descriptor.createModelManagerOptions({ apiKey: isAuthenticated(apiKey) ? apiKey : undefined, - baseUrl: this.#getProviderBaseUrlForDiscovery(descriptor.providerId), + baseUrl, }), - ); + authGeneration, + apiKey, + endpoint: this.#normalizeDiscoveryEvidenceEndpoint(baseUrl ?? ""), + }); } } for (let i = 0; i < enabledSpecialProviderDescriptors.length; i++) { const descriptor = enabledSpecialProviderDescriptors[i]; - const key = descriptor.resolveKey(specialKeys[i]); - if (!isAuthenticated(key)) { - continue; + const { apiKey: apiKeyValue, authGeneration } = specialProviderCredentials[i]; + const key = descriptor.resolveKey(apiKeyValue); + if ( + authGeneration !== undefined && + authGeneration === this.#getProviderEvidenceGeneration(descriptor.providerId, apiKeyValue) && + isAuthenticated(key) + ) { + const managerOptions = descriptor.createOptions(key); + options.push({ + options: managerOptions, + authGeneration, + apiKey: apiKeyValue, + endpoint: this.#normalizeDiscoveryEvidenceEndpoint( + this.#getProviderBaseUrlForDiscovery(descriptor.providerId) ?? "", + ), + }); } - options.push(descriptor.createOptions(key)); } return options; } async #discoverWithModelManager( - options: ModelManagerOptions, + { options, authGeneration, apiKey, endpoint }: ModelManagerDiscoveryOptions, strategy: ModelRefreshStrategy, ): Promise[]> { + const generation = (this.#descriptorDiscoveryGenerations.get(options.providerId) ?? 0) + 1; + this.#descriptorDiscoveryGenerations.set(options.providerId, generation); + const canUseCredentialDerivedXiaomiEndpoint = + options.providerId === "xiaomi" && + apiKey?.startsWith("tp-") === true && + this.#runtimeProviderOverrides.get("xiaomi")?.baseUrl === undefined && + this.#providerOverrides.get("xiaomi")?.baseUrl === undefined && + resolveProviderBaseUrlFromEnv("xiaomi") === undefined; + let credentialDerivedEndpoint: string | undefined; + const isCurrentDiscovery = () => + (this.#descriptorDiscoveryGenerations.get(options.providerId) ?? 0) === generation && + this.#getProviderEvidenceGeneration(options.providerId, apiKey) === authGeneration && + (endpoint === + this.#normalizeDiscoveryEvidenceEndpoint(this.#getProviderBaseUrlForDiscovery(options.providerId) ?? "") || + (canUseCredentialDerivedXiaomiEndpoint && credentialDerivedEndpoint !== undefined)); try { - const manager = createModelManager({ ...options, cacheDbPath: this.#cacheDbPath }); - const result = await manager.refresh(strategy); - return result.models.map(model => - model.provider === options.providerId ? model : { ...model, provider: options.providerId }, - ); + const manager = createModelManager({ + ...options, + cacheDbPath: this.#cacheDbPath, + canPublishCache: isCurrentDiscovery, + ...(options.fetchDynamicModels + ? { + fetchDynamicModels: async () => { + const models = await options.fetchDynamicModels?.(); + if (models === null) return null; + const sanitizedModels = this.#stripModelBaseUrlQueries(models ?? []); + if (canUseCredentialDerivedXiaomiEndpoint) { + credentialDerivedEndpoint = sanitizedModels[0]?.baseUrl; + } + return sanitizedModels; + }, + } + : {}), + ...(options.modelsDev + ? { + modelsDev: { + ...options.modelsDev, + map: (payload, providerId) => + this.#stripModelBaseUrlQueries(options.modelsDev?.map(payload, providerId) ?? []), + }, + } + : {}), + }); + const evidence = this.#descriptorDiscoveryEvidence.get(options.providerId); + const refreshStrategy = + strategy === "online-if-uncached" && + (evidence?.authGeneration !== authGeneration || evidence.endpoint !== endpoint) + ? "online" + : strategy; + const result = await manager.refresh(refreshStrategy); + const liveBaseUrl = this.#getProviderBaseUrlForDiscovery(options.providerId); + const models = result.models.map(model => { + const baseUrl = this.#restoreLiveDiscoveryBaseUrl(model.baseUrl, liveBaseUrl); + return { + ...(model.provider === options.providerId ? model : { ...model, provider: options.providerId }), + ...(baseUrl !== model.baseUrl ? { baseUrl } : {}), + }; + }); + if ( + isCurrentDiscovery() && + (result.fetched || + result.stale || + this.#descriptorDiscoveryEvidence.get(options.providerId)?.authGeneration !== authGeneration || + this.#descriptorDiscoveryEvidence.get(options.providerId)?.endpoint !== endpoint) + ) { + this.#descriptorDiscoveryEvidence.set(options.providerId, { + fresh: result.fetched, + modelIds: new Set(models.map(model => model.id)), + authGeneration, + endpoint: this.#normalizeDiscoveryEvidenceEndpoint(models[0]?.baseUrl ?? endpoint), + }); + } + if (!isCurrentDiscovery()) { + return []; + } + this.#providerEvidenceApiKeys.set(options.providerId, apiKey); + if (options.providerId === "opencodex" && !isAuthenticated(apiKey)) { + this.#credentiallessAuthFallbackProviders.set(options.providerId, authGeneration); + const evidence = this.#descriptorDiscoveryEvidence.get(options.providerId); + if (evidence?.authGeneration === authGeneration) { + this.#descriptorDiscoveryEvidence.set(options.providerId, { + ...evidence, + authGeneration: this.#getProviderEvidenceGeneration(options.providerId), + }); + } + } + return models; } catch (error) { + if (isCurrentDiscovery()) { + this.#providerEvidenceApiKeys.set(options.providerId, apiKey); + this.#descriptorDiscoveryEvidence.set(options.providerId, { + fresh: false, + modelIds: new Set(), + authGeneration, + endpoint, + }); + } logger.warn("model discovery failed for provider", { provider: options.providerId, error: error instanceof Error ? error.message : String(error), @@ -1938,10 +2523,21 @@ export class ModelRegistry { } } - async #discoverOllamaModels(providerConfig: DiscoveryProviderConfig): Promise[]> { + async #discoverOllamaModels( + providerConfig: DiscoveryProviderConfig, + discoveryApiKey?: string, + ): Promise[]> { const endpoint = this.#normalizeOllamaBaseUrl(providerConfig.baseUrl); const tagsUrl = `${endpoint}/api/tags`; - const headers = { ...(providerConfig.headers ?? {}) }; + const headers: Record = { ...(providerConfig.headers ?? {}) }; + const apiKey = + discoveryApiKey ?? + (this.#isCredentiallessProvider(providerConfig.provider) + ? kNoAuth + : await this.authStorage.getApiKey(providerConfig.provider)); + if (apiKey && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== kNoAuth) { + headers.Authorization = `Bearer ${apiKey}`; + } const response = await fetch(tagsUrl, { headers, signal: AbortSignal.timeout(250), @@ -2006,12 +2602,19 @@ export class ModelRegistry { } } - async #discoverLlamaCppModels(providerConfig: DiscoveryProviderConfig): Promise[]> { + async #discoverLlamaCppModels( + providerConfig: DiscoveryProviderConfig, + discoveryApiKey?: string, + ): Promise[]> { const baseUrl = this.#normalizeLlamaCppBaseUrl(providerConfig.baseUrl); const modelsUrl = `${baseUrl}/models`; const headers: Record = { ...(providerConfig.headers ?? {}) }; - const apiKey = await this.authStorage.getApiKey(providerConfig.provider); + const apiKey = + discoveryApiKey ?? + (this.#isCredentiallessProvider(providerConfig.provider) + ? kNoAuth + : await this.authStorage.getApiKey(providerConfig.provider)); if (apiKey && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== kNoAuth) { headers.Authorization = `Bearer ${apiKey}`; } @@ -2056,15 +2659,24 @@ export class ModelRegistry { return this.#applyProviderModelOverrides(providerConfig.provider, discovered); } - async #discoverOpenAIModelsList(providerConfig: DiscoveryProviderConfig): Promise[]> { + async #discoverOpenAIModelsList( + providerConfig: DiscoveryProviderConfig, + discoveryApiKey?: string, + ): Promise[]> { const baseUrl = this.#normalizeOpenAIModelsListBaseUrl(providerConfig.baseUrl); - const modelsUrl = `${baseUrl}/models`; + const modelsUrl = new URL(baseUrl); + const requestBaseUrl = baseUrl; + modelsUrl.pathname = `${modelsUrl.pathname.replace(/\/+$/g, "")}/models`; const headers: Record = { ...(providerConfig.headers ?? {}) }; // Resolve with the same baseUrl context completion requests use so an // endpoint-scoped (or config-pinned) credential wins here exactly as it // does for chat completions. - const apiKey = await this.authStorage.getApiKey(providerConfig.provider, undefined, { baseUrl }); + const apiKey = + discoveryApiKey ?? + (this.#isCredentiallessProvider(providerConfig.provider) + ? kNoAuth + : await this.authStorage.getApiKey(providerConfig.provider, undefined, { baseUrl })); if (apiKey && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== kNoAuth) { headers.Authorization = `Bearer ${apiKey}`; } @@ -2078,10 +2690,10 @@ export class ModelRegistry { // Redacted by construction: name the provider, endpoint, and the // config surface to fix — never the resolved key. throw new Error( - `HTTP ${response.status} from ${modelsUrl}: provider "${providerConfig.provider}" credential was rejected for OpenAI models-list discovery; check providers.${providerConfig.provider}.apiKey/apiKeyEnv.`, + `HTTP ${response.status} from ${redactDiscoveryUrl(modelsUrl)}: provider "${providerConfig.provider}" credential was rejected for OpenAI models-list discovery; check providers.${providerConfig.provider}.apiKey/apiKeyEnv.`, ); } - throw new Error(`HTTP ${response.status} from ${modelsUrl}`); + throw new Error(`HTTP ${response.status} from ${redactDiscoveryUrl(modelsUrl)}`); } const payload = (await response.json()) as { data?: Array<{ id: string }> }; const models = payload.data ?? []; @@ -2095,7 +2707,7 @@ export class ModelRegistry { name: id, api: providerConfig.api, provider: providerConfig.provider, - baseUrl, + baseUrl: requestBaseUrl, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -2124,7 +2736,6 @@ export class ModelRegistry { return raw; } } - #toLlamaCppNativeBaseUrl(baseUrl: string): string { try { const parsed = new URL(baseUrl); @@ -2136,7 +2747,43 @@ export class ModelRegistry { return baseUrl.endsWith("/v1") ? baseUrl.slice(0, -3) : baseUrl; } } - + #normalizeDiscoveryEvidenceEndpoint(endpoint: string): string { + try { + const parsed = new URL(endpoint); + const trimmedPath = parsed.pathname.replace(/\/+$/g, ""); + return `${parsed.protocol}//${parsed.host}${trimmedPath}${parsed.search}`; + } catch { + return endpoint.replace(/\/+$/g, ""); + } + } + #isCredentiallessProvider(provider: string): boolean { + let fallbackMatchesCurrentEvidence = false; + const fallbackEvidenceGeneration = this.#credentiallessAuthFallbackProviders.get(provider); + if (fallbackEvidenceGeneration !== undefined) { + try { + fallbackMatchesCurrentEvidence = + fallbackEvidenceGeneration === + this.authStorage.getProviderEvidenceGeneration(provider, this.#providerEvidenceApiKeys.get(provider)); + } catch { + // AuthStorage may be unavailable while a registry is being torn down. + } + } + return ( + this.#keylessProviders.has(provider) && + (!this.#optionalAuthProviders.has(provider) || + (!this.authStorage.hasAuth(provider) && !this.authStorage.has(provider)) || + fallbackMatchesCurrentEvidence) + ); + } + #getProviderEvidenceGeneration(provider: string, resolvedApiKey?: string): string { + if (this.#isCredentiallessProvider(provider)) { + return `credentialless:${provider}`; + } + return this.authStorage.getProviderEvidenceGeneration( + provider, + resolvedApiKey ?? this.#providerEvidenceApiKeys.get(provider), + ); + } #normalizeOpenAIModelsListBaseUrl(baseUrl?: string): string { const defaultBaseUrl = "http://127.0.0.1:1234/v1"; const raw = baseUrl || defaultBaseUrl; @@ -2144,7 +2791,7 @@ export class ModelRegistry { const parsed = new URL(raw); const trimmedPath = parsed.pathname.replace(/\/+$/g, ""); parsed.pathname = trimmedPath.endsWith("/v1") ? trimmedPath || "/v1" : `${trimmedPath}/v1`; - return `${parsed.protocol}//${parsed.host}${parsed.pathname}`; + return `${parsed.protocol}//${parsed.host}${parsed.pathname}${parsed.search}`; } catch { return raw; } @@ -2189,6 +2836,7 @@ export class ModelRegistry { #getProviderBaseUrlForDiscovery(provider: string): string | undefined { return ( + this.#runtimeProviderOverrides.get(provider)?.baseUrl ?? this.#providerOverrides.get(provider)?.baseUrl ?? resolveProviderBaseUrlFromEnv(provider) ?? this.getProviderBaseUrl(provider) @@ -2505,6 +3153,103 @@ export class ModelRegistry { this.#availableModelsEnvFingerprint = envFingerprint; return this.#availableModelsCache; } + #hasFreshOrStaticModelEvidence(model: Model): boolean { + const evidence = this.#providerActivity.get(model.provider); + if ( + !evidence || + (!evidence.staticConfigured && + !evidence.discoveryConfigured && + !evidence.implicitDiscovery && + !evidence.descriptorBacked) + ) { + return false; + } + if (evidence.staticConfigured && (!evidence.discoveryConfigured || evidence.staticModelIds.has(model.id))) { + return true; + } + if ( + evidence.descriptorFresh && + evidence.authGeneration === this.#getProviderEvidenceGeneration(model.provider) && + evidence.endpoint === + this.#normalizeDiscoveryEvidenceEndpoint( + this.#getProviderBaseUrlForDiscovery(model.provider) ?? model.baseUrl ?? "", + ) && + evidence.descriptorModelIds.has(model.id) + ) + return true; + const discoveryState = this.#discoveryManager.getState(model.provider); + const configuredEvidence = this.#configuredDiscoveryEvidence.get(model.provider); + return ( + (discoveryState?.status === "ok" || discoveryState?.status === "cached") && + configuredEvidence?.authGeneration === this.#getProviderEvidenceGeneration(model.provider) && + configuredEvidence.endpoint === + this.#normalizeDiscoveryEvidenceEndpoint(this.#getProviderBaseUrlForDiscovery(model.provider) ?? "") && + configuredEvidence.modelIds.has(model.id) + ); + } + + #activeConnectionKind(model: Model): ActiveProviderDescriptor["connectionKind"] | undefined { + const evidence = this.#providerActivity.get(model.provider); + if (!this.#isCredentiallessProvider(model.provider) && this.authStorage.hasUsableAuth(model.provider)) { + if (!evidence) return undefined; + const discoveryOnly = + !evidence.staticConfigured && + (evidence.discoveryConfigured || evidence.implicitDiscovery || evidence.descriptorBacked); + return !discoveryOnly || this.#hasFreshOrStaticModelEvidence(model) ? "credential" : undefined; + } + if (this.#isCredentiallessProvider(model.provider)) { + const discoveryOnly = + evidence !== undefined && + !evidence.staticConfigured && + (evidence.discoveryConfigured || evidence.implicitDiscovery || evidence.descriptorBacked); + if (discoveryOnly) { + if (evidence.descriptorBacked) + return this.#hasFreshOrStaticModelEvidence(model) ? "credentialless" : undefined; + const configuredBaseUrl = this.#getProviderBaseUrlForDiscovery(model.provider); + const discoveryType = this.#discoveryManager.providers.find( + provider => provider.provider === model.provider, + )?.discovery.type; + const endpointFor = (baseUrl: string | undefined): string => + this.#normalizeDiscoveryEvidenceEndpoint( + discoveryType === "ollama" + ? `${this.#normalizeOllamaBaseUrl(baseUrl)}/v1` + : discoveryType === "openai-models-list" || discoveryType === "lm-studio" + ? this.#normalizeOpenAIModelsListBaseUrl(baseUrl) + : (baseUrl ?? ""), + ); + if ( + endpointFor(model.baseUrl) !== endpointFor(configuredBaseUrl) || + this.#discoveryManager.getState(model.provider)?.status === "empty" + ) + return undefined; + const configuredEvidence = this.#configuredDiscoveryEvidence.get(model.provider); + if ( + this.#discoveryManager.getState(model.provider)?.error !== undefined || + (this.#optionalAuthProviders.has(model.provider) && + (configuredEvidence === undefined || + configuredEvidence.authGeneration !== this.#getProviderEvidenceGeneration(model.provider))) + ) + return undefined; + } + return "credentialless"; + } + return undefined; + } + + getActiveProviders(): ActiveProviderDescriptor[] { + try { + const descriptors: ActiveProviderDescriptor[] = []; + const disabledProviders = getDisabledProviderIdsFromSettings(); + const available = this.#models.filter(model => this.#isModelAvailable(model, disabledProviders)); + for (const model of available) { + const connectionKind = this.#activeConnectionKind(model); + if (connectionKind) descriptors.push({ provider: model.provider, connectionKind }); + } + return projectActiveProviderDescriptors(descriptors); + } catch { + throw new ActiveProviderResolutionError(); + } + } /** * Check whether auth is configured for a model's provider. @@ -2571,10 +3316,14 @@ export class ModelRegistry { } async #getApiKeyOrNoAuth(provider: string, lookup: () => Promise): Promise { - if (this.#keylessProviders.has(provider) && !this.authStorage.hasAuth(provider)) { - return kNoAuth; + if (!this.#isCredentiallessProvider(provider)) return lookup(); + if (!this.#optionalAuthProviders.has(provider)) return kNoAuth; + const apiKey = await lookup(); + if (apiKey !== undefined) { + this.#credentiallessAuthFallbackProviders.delete(provider); + return apiKey; } - return lookup(); + return kNoAuth; } /** @@ -2613,8 +3362,28 @@ export class ModelRegistry { ); } - async #peekApiKeyForProvider(provider: string): Promise { - return this.#getApiKeyOrNoAuth(provider, () => this.authStorage.peekApiKey(provider)); + async #peekApiKeyForProvider( + provider: string, + options: { + ignoreCredentiallessFallback?: boolean; + refreshOAuth?: boolean; + baseUrl?: string; + } = {}, + ): Promise { + if (!options.ignoreCredentiallessFallback && this.#isCredentiallessProvider(provider)) { + return kNoAuth; + } + try { + this.authStorage.getProviderEvidenceGeneration(provider); + } catch { + return undefined; + } + if (options.refreshOAuth && this.authStorage.hasOAuth(provider)) { + return this.authStorage.getApiKey(provider, undefined, { baseUrl: options.baseUrl }); + } + return options.ignoreCredentiallessFallback + ? this.authStorage.peekApiKey(provider) + : this.#getApiKeyOrNoAuth(provider, () => this.authStorage.peekApiKey(provider)); } /** @@ -2628,11 +3397,22 @@ export class ModelRegistry { return this.authStorage.getSessionCredentialType(provider, sessionId); } + #clearDescriptorDiscoveryEvidence(providerName: string): void { + this.#descriptorDiscoveryGenerations.set( + providerName, + (this.#descriptorDiscoveryGenerations.get(providerName) ?? 0) + 1, + ); + this.#descriptorDiscoveryEvidence.delete(providerName); + this.#configuredDiscoveryEvidence.delete(providerName); + this.#discoveryManager.invalidate(providerName); + } + #clearRuntimeProviderState(providerName: string): void { this.#runtimeProviderApiKeys.delete(providerName); this.#runtimeProviderOverrides.delete(providerName); this.#runtimeModelOverlays = this.#runtimeModelOverlays.filter(overlay => overlay.provider !== providerName); this.authStorage.removeConfigApiKey(providerName); + this.#clearDescriptorDiscoveryEvidence(providerName); } /** @@ -2654,6 +3434,7 @@ export class ModelRegistry { this.#clearRuntimeProviderState(providerName); } this.#lastStaticLoadMtime = null; + this.#staticModelsLoaded = false; this.#reloadStaticModels(); this.#rebuildCanonicalIndex(); } @@ -2698,6 +3479,8 @@ export class ModelRegistry { }, "runtime-register", ); + this.#clearDescriptorDiscoveryEvidence(providerName); + this.#rebuildProviderActivity(); if (config.streamSimple && config.api) { const streamSimple = config.streamSimple; @@ -2734,6 +3517,7 @@ export class ModelRegistry { } if (sourceHandoff) { this.#lastStaticLoadMtime = null; + this.#staticModelsLoaded = false; this.#reloadStaticModels(); } @@ -2791,12 +3575,14 @@ export class ModelRegistry { config.oauth.modifyModels(withRuntimeTransportOverride, credential), ); this.#rebuildCanonicalIndex(); + this.#rebuildProviderActivity(); return; } } this.#models = applyFinalCodexGpt56ContextCap(withRuntimeTransportOverride); this.#rebuildCanonicalIndex(); + this.#rebuildProviderActivity(); return; } @@ -2826,6 +3612,7 @@ export class ModelRegistry { return this.#applyProviderTransportOverride(m, transportOverride); }); this.#rebuildCanonicalIndex(); + this.#rebuildProviderActivity(); } } diff --git a/packages/coding-agent/src/config/resolve-config-value.ts b/packages/coding-agent/src/config/resolve-config-value.ts index defaada4ad..d06fd77f87 100644 --- a/packages/coding-agent/src/config/resolve-config-value.ts +++ b/packages/coding-agent/src/config/resolve-config-value.ts @@ -16,22 +16,24 @@ const commandInFlight = new Map>(); * Resolve a config value (API key, header value, etc.) to an actual value. * - If starts with "!", executes the rest as a shell command and uses stdout (cached) * - Otherwise checks environment variable first, then treats as literal (not cached) + * - `cacheScope` isolates command-cache entries when a caller rotates its config */ -export async function resolveConfigValue(config: string): Promise { +export async function resolveConfigValue(config: string, cacheScope?: string): Promise { if (config.startsWith("!")) { - return await executeCommand(config); + return await executeCommand(config, cacheScope); } const envValue = process.env[config]; return envValue || config; } -async function executeCommand(commandConfig: string): Promise { - const cached = commandResultCache.get(commandConfig); +async function executeCommand(commandConfig: string, cacheScope?: string): Promise { + const cacheKey = cacheScope === undefined ? commandConfig : `${cacheScope}\u0000${commandConfig}`; + const cached = commandResultCache.get(cacheKey); if (cached !== undefined) { return cached; } - const existing = commandInFlight.get(commandConfig); + const existing = commandInFlight.get(cacheKey); if (existing) { return await existing; } @@ -40,15 +42,15 @@ async function executeCommand(commandConfig: string): Promise { if (result !== undefined) { - commandResultCache.set(commandConfig, result); + commandResultCache.set(cacheKey, result); } return result; }) .finally(() => { - commandInFlight.delete(commandConfig); + commandInFlight.delete(cacheKey); }); - commandInFlight.set(commandConfig, promise); + commandInFlight.set(cacheKey, promise); return await promise; } diff --git a/packages/coding-agent/src/internal-urls/docs-index.generated.ts b/packages/coding-agent/src/internal-urls/docs-index.generated.ts index 4168a9dabe..b847b813e0 100644 --- a/packages/coding-agent/src/internal-urls/docs-index.generated.ts +++ b/packages/coding-agent/src/internal-urls/docs-index.generated.ts @@ -76,7 +76,7 @@ export const EMBEDDED_DOCS: Readonly> = { "sdk-app-guide.md": "# Building Applications on the Gajae-Code SDK\n\nA beginner-friendly guide to using Gajae-Code as the **agent runtime for your own\napplication** — mobile apps, desktop apps, custom web frontends, chat bots, and\nvertical AI products.\n\n> Proof that this works in production: the bundled **Telegram, Discord, and Slack\n> integrations are themselves ordinary SDK clients**. They use the exact same\n> public contract described here — no private hooks, no upstream changes.\n\nRelated references:\n\n- [SDK wire protocol & machine interfaces](./sdk.md) — the full WebSocket contract\n- [Embedding SDK](./sdk-embedding.md) — the in-process TypeScript API\n- [External control readiness](./external-control-readiness.md) — supported surfaces\n\n## Why build on Gajae-Code?\n\nEvery vertical AI app ends up needing the same backend pieces: an agentic loop,\ntool execution, session persistence, model/auth management, streaming, retries,\nand compaction. Some also need a configured remote-notification integration.\nTeams keep rebuilding these from scratch.\n\nGajae-Code packages the runtime as a reusable component:\n\n- **Drop the agentic loop from your codebase.** `createAgentSession()` gives you\n a production agent loop (tools, retries, compaction, session files, model\n fallback chains) in one call.\n- **A local machine interface is available by default.** Top-level sessions host\n a loopback WebSocket endpoint, so a client you build can observe actions and\n send replies without scraping a terminal. Remote transport, identity, and\n delivery remain your client's responsibility.\n- **Many subscribers, one session.** The event stream supports multiple\n subscribers: your app UI, a configured remote client, and an audit logger can\n all watch the same session simultaneously.\n- **Not just for coding.** Tools, skills, rules, and the system prompt are all\n injectable, so the same runtime powers legal assistants, research agents,\n data-analysis products — any vertical.\n\n\n## The two surfaces (pick one, or combine)\n\n| | Embedding SDK (in-process) | WebSocket SDK (out-of-process) |\n| --- | --- | --- |\n| What it is | Import `@gajae-code/coding-agent` as a library | Connect to a running session's loopback WS endpoint |\n| Language | TypeScript / Bun (Node-compatible) | Any language (JSON frames) |\n| Telemetry | Full: token deltas, tool events, session events | Curated: action/ask frames, summarized turn stream, queries |\n| Trust model | You are the host — full access | Token-authenticated client — secrets are never exposed |\n| Typical consumer | Your app's own UI and business logic | Bots, mobile clients, dashboards, orchestrators |\n\nA common production shape uses **both**: your app UI is the in-process\nsubscriber (full-fidelity streaming), while a configured remote client attaches\nover WebSocket for notifications and approvals.\n\n\n## Quick start: embed the runtime\n\n```bash\nbun add @gajae-code/coding-agent\n```\n\n```ts\nimport { createAgentSession } from \"@gajae-code/coding-agent\";\n\nconst { session } = await createAgentSession();\n\nsession.subscribe((event) => {\n if (\n event.type === \"message_update\" &&\n event.assistantMessageEvent.type === \"text_delta\"\n ) {\n process.stdout.write(event.assistantMessageEvent.delta); // token-level stream\n }\n});\n\nawait session.prompt(\"Summarize this repository in 3 bullets.\");\nawait session.dispose();\n```\n\n`createAgentSession()` follows *provide to override, omit to discover*: with no\noptions it auto-discovers auth, models, settings, tools, context files, and a\nfile-backed session store. Everything is overridable.\n\n## Customizing the runtime for your vertical\n\nThis is the part that turns Gajae-Code from \"a coding agent\" into a general\nexecution runtime. All of the following are `createAgentSession()` options; see\nthe [Embedding SDK](./sdk-embedding.md) for the public API.\n\n### Restrict or drop tools\n\n```ts\nconst { session } = await createAgentSession({\n // Allowlist of built-ins — everything else is dropped.\n toolNames: [\"read\", \"grep\", \"find\"],\n // Optionally restrict bash to specific command prefixes.\n bashAllowedPrefixes: [\"git status\", \"git log\"],\n});\n```\n\nRuntime changes are also supported: `session.getActiveToolNames()`,\n`session.getAllToolNames()`, `session.setActiveToolsByName(names)` — the system\nprompt is rebuilt automatically.\n\n### Add custom tools\n\n```ts\nconst { session } = await createAgentSession({\n toolNames: [\"read\"],\n customTools: [myDomainTool], // CustomTool | ToolDefinition\n // Or bring tools from an MCP server you own:\n mcpConfigPath: \"/abs/path/to/mcp-config.json\",\n});\n```\n\n### Inject skills, rules, and identity\n\n```ts\nconst { session } = await createAgentSession({\n skills: myVerticalSkills, // replaces bundled skill discovery\n rules: myRules,\n contextFiles: [{ path: \"DOMAIN.md\", content: domainKnowledge }],\n systemPrompt: (defaults) => [...defaults, myVerticalPromptBlock],\n promptTemplates: myTemplates,\n});\n```\n\n### Isolate state for request-scoped agents\n\n```ts\nimport { SessionManager, Settings } from \"@gajae-code/coding-agent\";\n\nconst { session } = await createAgentSession({\n sessionManager: SessionManager.inMemory(), // no filesystem persistence\n settings: Settings.isolated({ \"compaction.enabled\": true }),\n});\n```\n\n### Structured-output subagents\n\n`outputSchema`, `requireYieldTool`, `taskDepth`, and `parentTaskPrefix` support\norchestrator patterns where a session must return machine-readable results.\n\n### Observability\n\nPass `telemetry: {}` to enable OpenTelemetry GenAI-semantic-convention spans\n(no-op unless an OTEL SDK is registered in your host).\n\n## Quick start: attach from outside\n\nAny running top-level session (including one your embedded app created) writes a\ndiscovery file:\n\n```\n/.gjc/state/sdk/.json → { url, port, token, ... }\n```\n\nConnect with any WebSocket client (`ws://127.0.0.1:/?token=`), or\nuse the TypeScript transport package:\n\n```bash\nbun add @gajae-code/bridge-client\n```\n\n```ts\nimport { SdkClient } from \"@gajae-code/bridge-client\";\n```\n\nA minimal client only handles three frames:\n\n- `action_needed` — a question needs an answer (`kind: \"ask\"`) or the agent is idle\n- `action_resolved` — that action is no longer answerable\n- `reply_rejected` — your reply failed (e.g. `already_answered`)\n\nand sends one: `reply`. See [sdk.md](./sdk.md#minimal-client-example) for the\ncomplete example and the optional threaded frames (`turn_stream`,\n`context_update`, `activity`, `image_attachment`, …).\n\nBeyond frames, the WS surface exposes typed **control operations**\n(`turn.prompt`, `turn.steer`, `ask.answer`, `model.set`, `session.fork`,\n`bash.execute`, …) and **queries** (`transcript.list/body`, `diff.*`,\n`usage.get`, `models.list/current`, `workflow.gates.list`, …). See the\n[SDK wire protocol & machine interfaces](./sdk.md) for the complete catalog.\n\n\n## Creating and supervising sessions\n\nEmbedding creates a session directly with `createAgentSession()`. For an\nexternal controller that needs lifecycle operations, use Coordinator MCP or the\npublic daemon-session CLI. A lifecycle CLI request names the `global` action,\nprovides its operation and JSON input, and supplies a caller-chosen idempotency\nkey:\n\n```bash\ngjc daemon session global --op session.create \\\n --idempotency-key \\\n --json-input '{\"cwd\":\"/absolute/path/to/repo\"}'\n```\n\nThe CLI connects to the broker as needed; broker bootstrap is not an embedder\nAPI. See the [external controller integration guide](./bot-integration.md#integration-surfaces)\nfor the supported controller surfaces and lifecycle constraints.\n\n\n## Application recipes\n\n- **Vertical AI app (delete your agentic loop).** Embed with `toolNames` +\n `customTools` + `skills` + a domain `systemPrompt`. Your product UI subscribes\n in-process for token-level streaming. Add remote notifications or approvals\n only after configuring, enabling, and completing the required credentials or\n pairing for a managed adapter, or after deploying your own WS client; see\n [managed notification adapters](./sdk.md#managed-notification-adapters).\n- **Custom web app / dashboard.** Run sessions under the broker; your web\n backend attaches as a WS client, renders `turn_stream` snapshots, answers asks\n with `reply`, and reads history with `transcript.*` queries.\n- **Mobile / desktop companion.** Build a client for the WS contract: discover\n endpoints, render `action_needed`, and send `reply`. Threaded frames give you\n live activity and context updates.\n- **Fleet orchestrator.** Use Coordinator MCP or the documented daemon-session\n lifecycle operations to create and supervise many worktree-scoped sessions.\n\n## What the WS surface deliberately does not do\n\nSo you design around it rather than fight it:\n\n- **Loopback only.** Remote transport (like the Telegram daemon) is a\n client-side concern.\n- **No secrets on the wire.** `config.patch` rejects secret fields;\n `session.get_endpoint` is prohibited through chat adapters and MCP.\n- **Summarized streaming.** `turn_stream` is a throttled snapshot stream (no\n thinking tokens, redaction-gated). Full-fidelity token deltas are an\n in-process embedding capability.\n- **Fail-closed action identity.** One active answerable presentation at a\n time; stale IDs never regain authority. Do not retry by matching text.\n\nDestructive operations (`session.delete`, `context.clear`) require\n`confirm: true`.\n\n## FAQ\n\n**Is embedding a subprocess?** No — it is a library import; the agent loop runs\nin your process. Process isolation is what the broker/WS path is for.\n\n**Can multiple clients watch one session?** Yes. Subscribers are additive on\nboth surfaces; replies to asks are arbitrated first-valid-wins.\n\n**Can the TUI and my code share a session?** Concurrently: run the TUI and\nattach your code as a WS client. Sequentially: sessions are `.jsonl` files —\nresume/fork/handoff between your embedded app and `gjc`.\n\n**I need full streaming in another language.** Today: spawn a session and use\nthe WS contract, or wrap the embedding SDK in a small TS host you own.\nDedicated embedding-like Rust/Python SDKs are tracked as roadmap issues.\n", "sdk-embedding.md": "# SDK\n\nFor the external control and notification wire protocol, see [the Gajae-Code SDK](./sdk.md).\n\nThe SDK is the in-process integration surface for `@gajae-code/coding-agent`.\nUse it when you want direct access to agent state, event streaming, tool wiring, and session control from your own Bun/Node process.\n\nFor cross-language or process-isolated control, use the [SDK WebSocket machine interface](./sdk.md).\n\n## Installation\n\n```bash\nbun add @gajae-code/coding-agent\n```\n\nFor process-isolated TypeScript integrations, install `@gajae-code/bridge-client` and import `SdkClient` from that standalone transport-only package. `@gajae-code/coding-agent/sdk` remains a compatibility re-export with the same `SdkClient` class identity and associated types. Both surfaces use only the v3 SDK transport; no historical BridgeClient backend protocol, handshake/commands/SSE endpoint, or direct host-control path is restored.\n\n## Entry points\n\n`@gajae-code/coding-agent/sdk` is the canonical entry point for embedders. The package root exports the same SDK APIs for convenience.\n\nCore exports for embedders:\n\n- `createAgentSession`\n- `SessionManager`\n- `Settings`\n- `AuthStorage`\n- `ModelRegistry`\n- `discoverAuthStorage`\n- Discovery helpers for retained context/prompt surfaces (`discoverContextFiles`, `discoverPromptTemplates`)\n- Tool factory surface (`createTools`, `BUILTIN_TOOLS`, tool classes)\n\n## Quick start (auto-discovery defaults)\n\n```ts\nimport { createAgentSession } from \"@gajae-code/coding-agent\";\n\nconst { session, modelFallbackMessage } = await createAgentSession();\n\nif (modelFallbackMessage) {\n process.stderr.write(`${modelFallbackMessage}\\n`);\n}\n\nconst unsubscribe = session.subscribe((event) => {\n if (\n event.type === \"message_update\" &&\n event.assistantMessageEvent.type === \"text_delta\"\n ) {\n process.stdout.write(event.assistantMessageEvent.delta);\n }\n});\n\nawait session.prompt(\"Summarize this repository in 3 bullets.\");\nunsubscribe();\nawait session.dispose();\n```\n\n## What `createAgentSession()` discovers by default\n\n`createAgentSession()` follows “provide to override, omit to discover”.\n\nIf omitted, it resolves:\n\n- `cwd`: `getProjectDir()`\n- `agentDir`: `~/.gjc/agent` (via `getAgentDir()`)\n- `authStorage`: `discoverAuthStorage(agentDir)`\n- `modelRegistry`: `new ModelRegistry(authStorage)` + background `refreshInBackground()` when the registry is not provided\n- `settings`: `await Settings.init({ cwd, agentDir })`\n- `sessionManager`: `SessionManager.create(cwd)` (file-backed)\n- context files and prompt templates\n- built-in tools via `createTools(...)`\n- LSP integration (enabled by default)\n- `eventBus`: new `EventBus()` unless supplied\n\n### Required vs optional inputs\n\nTypically you must provide only what you want to control:\n\n- **Must provide**: nothing for a minimal session\n- **Usually provide explicitly** in embedders:\n - `sessionManager` (if you need in-memory or custom location)\n - `authStorage` + `modelRegistry` (if you own credential/model lifecycle)\n - `model` or `modelPattern` (if deterministic model selection matters)\n - `settings` (if you need isolated/test config)\n\n## Session manager behavior (persistent vs in-memory)\n\n`AgentSession` always uses a `SessionManager`; behavior depends on which factory you use.\n\n### File-backed (default)\n\n```ts\nimport { createAgentSession, SessionManager } from \"@gajae-code/coding-agent\";\n\nconst { session } = await createAgentSession({\n sessionManager: SessionManager.create(process.cwd()),\n});\n\nconsole.log(session.sessionFile); // absolute .jsonl path\n```\n\n- Persists conversation/messages/state deltas to session files.\n- Supports resume/open/list/fork workflows.\n- `session.sessionFile` is defined.\n\n### In-memory\n\n```ts\nimport { createAgentSession, SessionManager } from \"@gajae-code/coding-agent\";\n\nconst { session } = await createAgentSession({\n sessionManager: SessionManager.inMemory(),\n});\n\nconsole.log(session.sessionFile); // undefined\n```\n\n- No filesystem persistence.\n- Useful for tests, ephemeral workers, request-scoped agents.\n- Session methods still work, but persistence-specific behaviors (file resume/fork paths) are naturally limited.\n\n### Resume/open/list helpers\n\n```ts\nimport { SessionManager } from \"@gajae-code/coding-agent\";\n\nconst recent = await SessionManager.continueRecent(process.cwd());\nconst listed = await SessionManager.list(process.cwd());\nconst opened = listed[0] ? await SessionManager.open(listed[0].path) : null;\n```\n\n## Model and auth wiring\n\n`createAgentSession()` uses `ModelRegistry` + `AuthStorage` for model selection and API key resolution.\n\n### Explicit wiring\n\n```ts\nimport {\n createAgentSession,\n discoverAuthStorage,\n ModelRegistry,\n SessionManager,\n} from \"@gajae-code/coding-agent\";\n\nconst authStorage = await discoverAuthStorage();\nconst modelRegistry = new ModelRegistry(authStorage);\nawait modelRegistry.refresh();\n\nconst available = modelRegistry.getAvailable();\nif (available.length === 0)\n throw new Error(\"No authenticated models available\");\n\nconst { session } = await createAgentSession({\n authStorage,\n modelRegistry,\n model: available[0],\n thinkingLevel: \"medium\",\n sessionManager: SessionManager.inMemory(),\n});\n```\n\n### Selection order when `model` is omitted\n\nWhen no explicit `model`/`modelPattern` is provided:\n\n1. restore model from existing session (if restorable + key available)\n2. settings default model role (`default`)\n3. first available model with valid auth\n\nIf restore fails, `modelFallbackMessage` explains fallback.\n\n### Auth priority\n\n`AuthStorage.getApiKey(...)` resolves in this order:\n\n1. runtime override (`setRuntimeApiKey`)\n2. stored credentials in `agent.db`\n3. provider environment variables\n4. custom-provider resolver fallback (if configured)\n\n## Event subscription model\n\nSubscribe with `session.subscribe(listener)`; it returns an unsubscribe function.\n\n```ts\nconst unsubscribe = session.subscribe((event) => {\n switch (event.type) {\n case \"agent_start\":\n case \"turn_start\":\n case \"tool_execution_start\":\n break;\n case \"message_update\":\n if (event.assistantMessageEvent.type === \"text_delta\") {\n process.stdout.write(event.assistantMessageEvent.delta);\n }\n break;\n }\n});\n```\n\n`AgentSessionEvent` includes core `AgentEvent` plus session-level events:\n\n- `auto_compaction_start` / `auto_compaction_end`\n- `auto_retry_start` / `auto_retry_end`\n- `retry_fallback_applied` / `retry_fallback_succeeded`\n- `ttsr_triggered`\n- `todo_reminder` / `todo_auto_clear`\n- `irc_message`\n\n## Prompt lifecycle\n\n`session.prompt(text, options?)` is the primary entry point.\n\nBehavior:\n\n1. optional command/template expansion (`/` commands, custom commands, file slash commands, prompt templates)\n2. if currently streaming:\n - requires `streamingBehavior: \"steer\" | \"followUp\"`\n - queues instead of throwing work away\n3. if idle:\n - validates model + API key\n - appends user message\n - starts agent turn\n\nRelated APIs:\n\n- `sendUserMessage(content, { deliverAs? })`\n- `steer(text, images?)`\n- `followUp(text, images?)`\n- `sendCustomMessage({ customType, content, ... }, { deliverAs?, triggerTurn? })`\n- `abort()`\n\n## Tools integration\n\n### Built-ins and filtering\n\n- Built-ins come from `createTools(...)` and `BUILTIN_TOOLS`.\n- `toolNames` acts as an allowlist for built-ins.\n- Hidden tools (for example `yield`) are opt-in unless required by options.\n\n```ts\nconst { session } = await createAgentSession({\n toolNames: [\"read\", \"search\", \"find\", \"write\"],\n requireYieldTool: true,\n});\n```\n\n### Runtime tool set changes\n\n`AgentSession` supports runtime activation updates:\n\n- `getActiveToolNames()`\n- `getAllToolNames()`\n- `setActiveToolsByName(names)`\n\nSystem prompt is rebuilt to reflect active tool changes.\n\n## Discovery helpers\n\nUse these when you want partial control without recreating internal discovery logic:\n\n- `discoverAuthStorage(agentDir?)`\n- `discoverContextFiles(cwd?, _agentDir?)`\n- `discoverPromptTemplates(cwd?, agentDir?)`\n- `buildSystemPrompt(options?)`\n\n## Subagent-oriented options\n\nFor SDK consumers building orchestrators (similar to task executor flow):\n\n- `outputSchema`: passes structured output expectation into tool context\n- `requireYieldTool`: forces `yield` tool inclusion\n- `taskDepth`: recursion-depth context for nested task sessions\n- `parentTaskPrefix`: artifact naming prefix for nested task outputs\n\nThese are optional for normal single-agent embedding.\n\n## `createAgentSession()` return value\n\n```ts\ntype CreateAgentSessionResult = {\n session: AgentSession;\n setToolUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void;\n modelFallbackMessage?: string;\n lspServers?: Array<{\n name: string;\n status: \"ready\" | \"error\";\n fileTypes: string[];\n error?: string;\n }>;\n eventBus: EventBus;\n};\n```\n\nUse `setToolUIContext(...)` only if your embedder provides UI capabilities that tools should call into.\n\n## Startup performance\n\n`createAgentSession()` runs two background optimizations to overlap I/O with the rest of session setup:\n\n- **Model-host preconnect.** As soon as the model is resolved, the SDK fires a best-effort `fetch.preconnect(model.baseUrl)` so DNS + TCP + TLS + HTTP/2 to the provider's host happens in parallel with tool registry build, and system-prompt assembly. The first real `fetch(...)` then reuses the warm connection, saving 100–300 ms on transcontinental hops (e.g. residential IP → `api.anthropic.com`). Implementation lives in `preconnectModelHost()` in `packages/coding-agent/src/sdk/session.ts`. If `fetch.preconnect` is unavailable (non-Bun runtime) or the call throws, the optimization is silently skipped — never a hard dependency. Applies to interactive, print, and ACP modes.\n- **Conditional LSP warmup.** Startup LSP servers (those returned by `discoverStartupLspServers(cwd)`) are only warmed when **all** of these hold:\n - `enableLsp !== false` on the session options, **and**\n - `options.hasUI === true` (interactive TUI), **and**\n - the `lsp.diagnosticsOnWrite` setting is enabled.\n\n Print, script, and ACP invocations (`hasUI=false`) skip the warmup entirely: they don't render the warmup status indicator and typically finish before the language servers would stabilize, so warming them just spends CPU parsing big `initialize` responses concurrently with the LLM stream consumer and jitters perceived latency. Tools that actually need an LSP server still spin one up on demand through `getOrCreateClient()` — only the *startup* warmup is skipped. The returned `lspServers` field in `CreateAgentSessionResult` is therefore `undefined` (not an empty array) whenever the warmup branch was bypassed.\n\n## Minimal controlled embed example\n\n```ts\nimport {\n createAgentSession,\n discoverAuthStorage,\n ModelRegistry,\n SessionManager,\n Settings,\n} from \"@gajae-code/coding-agent\";\n\nconst authStorage = await discoverAuthStorage();\nconst modelRegistry = new ModelRegistry(authStorage);\nawait modelRegistry.refresh();\n\nconst settings = Settings.isolated({\n \"compaction.enabled\": true,\n \"retry.enabled\": true,\n});\n\nconst { session } = await createAgentSession({\n authStorage,\n modelRegistry,\n settings,\n sessionManager: SessionManager.inMemory(),\n toolNames: [\"read\", \"search\", \"find\", \"edit\", \"write\"],\n enableLsp: true,\n});\n\nsession.subscribe((event) => {\n if (\n event.type === \"message_update\" &&\n event.assistantMessageEvent.type === \"text_delta\"\n ) {\n process.stdout.write(event.assistantMessageEvent.delta);\n }\n});\n\nawait session.prompt(\"Find all TODO comments in this repo and propose fixes.\");\nawait session.dispose();\n```\n", "sdk-rpc-parity-audit.md": "# SDK v3 RPC parity audit\n\n**Status:** internal, closed-inventory audit. This is a comparison of the retired\nRPC contract at `6e147d58~1:docs/rpc.md` with SDK v3; it is not an event-plane\nparity claim. The CLI rejects the retired `--mode rpc`, `rpc-ui`, and `bridge`\nmodes and directs external control to the SDK (`packages/coding-agent/src/cli/args.ts:117-127`).\n\nThe historical issue files under `issues/01`–`issues/13` are retained as provenance only. Their current disposition is recorded in `issues/README.md`: implementation findings are resolved, retired RPC documentation findings are obsolete, and persistent-session/registry items remain deferred architectural follow-ups. Do not treat this closed audit as an active implementation backlog.\n\n## Method and classifications\n\nThe inventory below is **closed**. Command, frame, and sub-protocol rows were\nrecovered from `git show 6e147d58~1:docs/rpc.md`; the supplemental\n`rpc-sessions` registry and `--listen` Unix-socket rows were recovered from\nparent-commit source because they do not appear in that document:\n`6e147d58~1:packages/coding-agent/src/cli/args.ts:157-158`,\n`6e147d58~1:packages/coding-agent/src/modes/rpc/rpc-mode.ts:892-907,984-992`,\nand\n`6e147d58~1:packages/coding-agent/src/modes/shared/agent-wire/session-registry.ts:1-53`.\n`SDK equivalent` means a current operation or documented SDK protocol covers the\ncontrol/query intent, not that its transport or event semantics are identical.\n`transport-gap — closed by Phase 1` means Phase 1's `gjc sdk serve` and typed\n`gjc_sdk` Python package provide the replacement transport/client surface.\n`phase-2-gap` means no equivalent has been implemented by this audit.\n\nOperation names and their stated roles are from\n`packages/coding-agent/src/sdk/protocol/operation-registry.ts:66-166`; dispatch\ncoverage is from `packages/coding-agent/src/sdk/host/control/dispatch.ts:138-253`.\nSDK protocol and lifecycle references use stable heading references in\n`docs/sdk.md`. Command, frame, and sub-protocol rows cite\n`6e147d58~1:docs/rpc.md`; the two supplemental rows cite the parent-commit\nsources above.\n\n## Closed command inventory\n\n| Retired family | Retired command | SDK v3 equivalent or classification | Evidence |\n| --- | --- | --- | --- |\n| Prompting | `prompt` | `turn.prompt` | retired doc; registry:67; dispatch:139-140 |\n| Prompting | `steer` | Partial SDK equivalent: `turn.steer` is text-only and loses retired `images` | `6e147d58~1:docs/rpc.md:77`; registry:68; dispatch:141-142 |\n| Prompting | `follow_up` | Partial SDK equivalent: `turn.follow_up` is text-only and loses retired `images` | `6e147d58~1:docs/rpc.md:78`; registry:69; dispatch:143-144 |\n| Prompting | `abort` | `turn.abort` | retired doc; registry:70; dispatch:145-146 |\n| Prompting | `abort_and_prompt` | `turn.abort_and_prompt` | retired doc; registry:71; dispatch:147-148 |\n| Prompting | `new_session` | Partial SDK equivalent: `session.new` takes no input and loses retired `parentSession` | `6e147d58~1:docs/rpc.md:81`; registry:93; dispatch:196-197 |\n| State | `get_state` | Partial SDK equivalent: query bundle `context.get` (includes `systemPrompt`), `tools.list` (Q20), `models.list/current`, `todo.list`, `queue.messages.list`, `session.metadata`, and `session.stats`; no one-shot legacy-shaped snapshot, no retired `dumpTools` include-toggle/exact dump schema, and some legacy snapshot fields remain absent | `6e147d58~1:docs/rpc.md:85,169-222`; registry:132-152; sdk/bus/index.ts:1804-1808,1852-1855; host/query/handlers.ts:91,116; docs/sdk.md “Protocol” and “Model catalog query (Q10)” |\n| State | `set_todos` | `todo.replace` | retired doc; registry:78; dispatch:166-167 |\n| State | `set_host_tools` | Partial SDK equivalent — provider-only/machine attachment; not installed on the ordinary per-session endpoint: `host_tools.register` | `6e147d58~1:docs/rpc.md:87,255-291`; registry:105,164; dispatch:220-221; sdk/bus/index.ts:1654,1726-1738,2325-2327 |\n| State | `set_host_uri_schemes` | Partial SDK equivalent — provider-only/machine attachment; not installed on the ordinary per-session endpoint: `host_uri.register` | `6e147d58~1:docs/rpc.md:88,293-323`; registry:106,165; dispatch:222-223; sdk/bus/index.ts:1654,1726-1738,2325-2327 |\n| State | `workflow_gate_response` | `workflow.gate_answer` (durable Q12 gate ID) | retired doc; registry:73; dispatch:151-157; docs/sdk.md “Durable workflow controls and Q12” |\n| Model | `set_model` | `model.set` | retired doc; registry:79; dispatch:168-169 |\n| Model | `set_default_model_selection` | `model.set` with `thinkingLevel`; equivalent active-model/default-selection intent, not the retired durable-selector response envelope | retired doc; registry:79; dispatch:168-169; docs/sdk.md “Model catalog query (Q10)” |\n| Model | `cycle_model` | `model.cycle` | retired doc; registry:80; dispatch:170-171 |\n| Model | `get_available_models` | `models.list/current` / Q10 | retired doc; registry:141; docs/sdk.md “Model catalog query (Q10)” |\n| Thinking | `set_thinking_level` | `thinking.set` | retired doc; registry:81; dispatch:172-173 |\n| Thinking | `cycle_thinking_level` | `thinking.cycle` | retired doc; registry:82; dispatch:174-175 |\n| Queue modes | `set_steering_mode` | `queue.steering_mode.set` | retired doc; registry:84; dispatch:178-179 |\n| Queue modes | `set_follow_up_mode` | `queue.follow_up_mode.set` | retired doc; registry:85; dispatch:180-181 |\n| Queue modes | `set_interrupt_mode` | `queue.interrupt_mode.set` | retired doc; registry:86; dispatch:182-183 |\n| Compaction | `compact` | Partial SDK equivalent: `compaction.run` takes no input and loses retired `customInstructions` | `6e147d58~1:docs/rpc.md:111`; registry:87; dispatch:184-185 |\n| Compaction | `set_auto_compaction` | `compaction.auto.set` | retired doc; registry:88; dispatch:186-187 |\n| Retry | `set_auto_retry` | `retry.auto.set` | retired doc; registry:89; dispatch:188-189 |\n| Retry | `abort_retry` | `retry.abort` | retired doc; registry:90; dispatch:190-191 |\n| Bash | `bash` | `bash.execute` | retired doc; registry:91; dispatch:192-193 |\n| Bash | `abort_bash` | `bash.abort` | retired doc; registry:92; dispatch:194-195 |\n| Session | `get_session_stats` | `session.stats` | retired doc; registry:146; docs/sdk.md “Protocol” |\n| Session | `export_html` | Partial SDK equivalent: `session.export_html` takes no input and loses retired `outputPath` | `6e147d58~1:docs/rpc.md:127`; registry:101; dispatch:212-213 |\n| Session | `switch_session` | Partial SDK equivalent: retired `switch_session` was path-addressed (`sessionPath`), while `session.switch` is ID-addressed | `6e147d58~1:docs/rpc.md:128`; registry:97; dispatch:204-205 |\n| Session | `branch` | `session.branch` | retired doc; registry:98; dispatch:206-207 |\n| Session | `get_branch_messages` | `session.branch_candidates` plus `transcript.list`/`transcript.body`; no identical combined payload | retired doc; registry:132-133,147; docs/sdk.md “Protocol” |\n| Session | `get_last_assistant_text` | `session.last_assistant` | retired doc; registry:148; docs/sdk.md “Protocol” |\n| Session | `set_session_name` | `session.rename` | retired doc; registry:99; dispatch:208-209 |\n| Messages | `get_messages` | `transcript.list` and `transcript.body`; no identical monolithic payload | retired doc; registry:132-133; docs/sdk.md “Protocol” |\n\n## Closed framing, sub-protocol, registry, and transport inventory\n\n| Retired family | Retired frame, protocol, or transport | SDK v3 equivalent or classification | Evidence |\n| --- | --- | --- | --- |\n| Outbound frame | `ready` | transport-gap — closed by Phase 1; WebSocket connection/authentication replaces JSONL readiness | retired doc; docs/sdk.md §Endpoint discovery |\n| Outbound frame | `response` | transport-gap — closed by Phase 1; SDK control request/response replaces JSONL `RpcResponse` | retired doc; registry:66-119; dispatch:138-253 |\n| Outbound frame | canonical `event` | phase-2-gap; no renderer-grade canonical `AgentSessionEvent` stream | retired doc; docs/sdk.md §Protocol |\n| Outbound frame | `workflow_gate` | Partial SDK equivalent: `action_needed` with `workflowGateId`, plus Q12; not the retired frame/schema | retired doc; docs/sdk.md §Server → client, §Durable workflow controls and Q12 |\n| Outbound frame | `extension_ui_request` | phase-2-gap for extension UI methods; `action_needed` covers only generic asks | retired doc; docs/sdk.md §Server → client |\n| Outbound frame | `host_tool_call`, `host_tool_cancel` | Partial SDK equivalent — provider-only/machine attachment; not installed on the ordinary per-session endpoint: reverse `host_tool.invoke/cancel/update/result` with `host_tools.register` | `6e147d58~1:docs/rpc.md:45-46,357`; registry:105,164; dispatch:220-221; sdk/bus/index.ts:1654,1726-1738,2325-2327 |\n| Outbound frame | `host_uri_request`, `host_uri_cancel` | Partial SDK equivalent — provider-only/machine attachment; not installed on the ordinary per-session endpoint: reverse `host_uri.read/write/cancel/result` with `host_uri.register` | `6e147d58~1:docs/rpc.md:46,357`; registry:106,165; dispatch:222-223; sdk/bus/index.ts:1654,1726-1738,2325-2327 |\n| Outbound frame | `extension_error` | phase-2-gap; no SDK extension-error frame contract | retired doc; docs/sdk.md §Protocol |\n| Inbound frame | `RpcCommand` | SDK control and query operations | retired doc; registry:66-157; dispatch:138-253 |\n| Inbound frame | `workflow_gate_response` | `workflow.gate_answer` | retired doc; registry:73; docs/sdk.md “Durable workflow controls and Q12” |\n| Inbound frame | `extension_ui_response` | phase-2-gap except generic `reply` for an `action_needed` ask | retired doc; docs/sdk.md §Client → server |\n| Inbound frame | `host_tool_update`, `host_tool_result` | Partial SDK equivalent — provider-only/machine attachment; not installed on the ordinary per-session endpoint: reverse `host_tool.invoke/cancel/update/result` | `6e147d58~1:docs/rpc.md:54`; registry:164; dispatch:220-221; sdk/bus/index.ts:1654,1726-1738,2325-2327 |\n| Inbound frame | `host_uri_result` | Partial SDK equivalent — provider-only/machine attachment; not installed on the ordinary per-session endpoint: reverse `host_uri.read/write/cancel/result` | `6e147d58~1:docs/rpc.md:55`; registry:165; dispatch:222-223; sdk/bus/index.ts:1654,1726-1738,2325-2327 |\n| Workflow gate sub-protocol | `workflow_gate` / `workflow_gate_response` with schema and durable broker semantics | Partial SDK equivalent: `action_needed`, `reply`, Q12 `workflow.gates.list`, and `workflow.gate_answer`; IDs and authority rules differ | retired doc; registry:73,143; docs/sdk.md “Answer semantics” and “Durable workflow controls and Q12” |\n| Extension UI sub-protocol | select/confirm/input/editor/cancel/notify/status/widget/title/editor-text | phase-2-gap; generic action presentation is not extension UI parity | retired doc; docs/sdk.md §Server → client |\n| Host tool sub-protocol | registration, call/cancel, update/result | Partial SDK equivalent — provider-only/machine attachment; not installed on the ordinary per-session endpoint: `host_tools.register` plus reverse callback operations | `6e147d58~1:docs/rpc.md:45,54,255-291,357`; registry:105,164; dispatch:220-221; sdk/bus/index.ts:1654,1726-1738,2325-2327 |\n| Host URI sub-protocol | scheme registration, read/write/cancel/result | Partial SDK equivalent — provider-only/machine attachment; not installed on the ordinary per-session endpoint: `host_uri.register` plus reverse callback operations | `6e147d58~1:docs/rpc.md:46,55,293-323,357`; registry:106,165; dispatch:222-223; sdk/bus/index.ts:1654,1726-1738,2325-2327 |\n| Unattended sub-protocol | `negotiate_unattended` declaration/budget/scopes/allowlist | phase-2-gap | retired doc; docs/sdk.md §Coordinator MCP question pull loop |\n| `rpc-sessions` registry | Cross-process session registry and reattach semantics | phase-2-gap. Per-session discovery files are only partial endpoint location, not a registry/reattach protocol | parent source: `6e147d58~1:packages/coding-agent/src/modes/rpc/rpc-mode.ts:892-907,984-992`; `6e147d58~1:packages/coding-agent/src/modes/shared/agent-wire/session-registry.ts:1-53`; docs/sdk.md §Endpoint discovery, §Architecture |\n| Transport | stdio JSONL | transport-gap — closed by Phase 1 (`gjc sdk serve` + `gjc_sdk` typed Python client) | retired doc; Phase 1 approved plan; removal evidence `args.ts:117-127` |\n| Transport | `--listen` Unix socket | transport-gap — closed by Phase 1 (`gjc sdk serve` + `gjc_sdk` typed Python client); replacement is not Unix-socket wire compatibility | parent source: `6e147d58~1:packages/coding-agent/src/cli/args.ts:157-158`; `6e147d58~1:packages/coding-agent/src/modes/rpc/rpc-mode.ts:892-971`; Phase 1 approved plan; docs/sdk.md §Endpoint discovery; removal evidence `args.ts:117-127` |\n\n## Five-gap reduction verdict\n\nSDK v3 has broad control/query coverage: the operation registry includes turn,\nmodel, thinking, queue, compaction, retry, bash, session, host callback, and\nworkflow operations (`operation-registry.ts:66-166`), and control dispatch\nimplements the control path (`dispatch.ts:138-253`). That does **not** erase the\nuser-perceived reduction. It is **REAL** across five dimensions:\n\n1. **stdio JSONL and Unix-socket transports.** Phase 1 (`gjc sdk serve` plus the\n typed `gjc_sdk` Python package) closes this transport/client gap, while not\n promising byte-for-byte JSONL or Unix-socket compatibility.\n2. **Typed Python client.** Phase 1 closes the absence of a supported typed\n Python client through `gjc_sdk`.\n3. **`negotiate_unattended`.** No fail-closed unattended negotiation with the\n retired declaration, budget, scope, and allowlist exists: this remains Phase 2.\n4. **Cross-process session registry/reattach.** Discovery files locate a live\n endpoint but do not provide the retired registry or reattach lifecycle: this\n remains Phase 2.\n5. **Renderer-grade full event stream.** SDK v3's minimal frames and optional\n threaded-client frames are not the retired canonical session event stream.\n **No event-plane parity is claimed.**\n\n## Ranked Phase-2 follow-up register — NOT implemented\n\n1. **Unattended negotiation equivalent — NOT implemented.** Add a fail-closed\n equivalent to `negotiate_unattended` only with explicit actor, budget, scopes,\n allowlist, and audit enforcement. Partial equivalent only: Q12\n `workflow.gates.list` plus the Coordinator MCP pull loop can enumerate and\n answer durable workflow gates; they are not unattended negotiation\n (`docs/sdk.md §Coordinator MCP question pull loop`).\n2. **Reattach/registry — NOT implemented.** Define cross-process registry and\n reattachment semantics. Partial equivalent only: discovery files at\n `.gjc/state/sdk/.json` provide endpoint location and token for a\n live session (`docs/sdk.md §Endpoint discovery`); architecture explicitly says there is no\n shared upstream registry (`docs/sdk.md §Architecture`).\n3. **Full event stream — NOT implemented.** Define a renderer-grade session\n event contract only if consumers require it. Partial equivalent only:\n `action_needed`, `action_resolved`, `reply_rejected`, and optional threaded\n frames such as `turn_stream` exist, but there is **no `onSessionEvent`-style\n SDK equivalent** (`docs/sdk.md §Server → client`).\n\n## Completeness checklist\n\n- [x] Prompting — every retired command represented.\n- [x] State — every retired command represented.\n- [x] Model — every retired command represented.\n- [x] Thinking — every retired command represented.\n- [x] Queue modes — every retired command represented.\n- [x] Compaction — every retired command represented.\n- [x] Retry — every retired command represented.\n- [x] Bash — every retired command represented.\n- [x] Session — every retired command represented.\n- [x] Messages — every retired command represented.\n- [x] Outbound and inbound frame categories — every retired category represented.\n- [x] Workflow gate sub-protocol represented.\n- [x] Extension UI sub-protocol represented.\n- [x] Host tool sub-protocol represented.\n- [x] Host URI sub-protocol represented.\n- [x] `negotiate_unattended` sub-protocol represented.\n- [x] `rpc-sessions` registry represented from parent-commit source (supplemental to the recovered document inventory).\n- [x] stdio JSONL represented from the recovered document inventory; `--listen` Unix-socket transport represented from parent-commit source.\n", - "sdk.md": "# Gajae-Code SDK\n\nFor embedding GJC in-process, see [the embedding SDK guide](./sdk-embedding.md).\nFor a beginner-friendly application development guide (recipes, customization, and surface selection), see [Building applications on the SDK](./sdk-app-guide.md).\n\n

\n \"Gajae\n

\n\nA small, transport-agnostic SDK for receiving **action-needed** signals from a\nGJC session and sending **replies** back without scraping the terminal.\n\nThe stable contract is deliberately generic: every top-level running session\nhosts one loopback WebSocket endpoint by default, and integrations are\nuser-written clients that connect to that endpoint. Telegram, Discord, Slack,\nmobile apps, and local tools all use the same JSON protocol. No upstream Rust,\nN-API, or wire-protocol change is required for a new integration.\n\n> Status: the Rust core (`crates/gjc-sdk`) provides the wire protocol, action\n> lifecycle, loopback WebSocket server, and endpoint discovery file. The bundled\n> Telegram daemon is a reference client layered on top of this SDK; it is not the\n> upstream topology.\n\n## TypeScript transport client\n\nInstall the standalone transport-only client when connecting to the v3 SDK WebSocket endpoint from TypeScript:\n\n```bash\nbun add @gajae-code/bridge-client\n```\n\n```ts\nimport { SdkClient } from \"@gajae-code/bridge-client\";\n```\n\n`@gajae-code/coding-agent/sdk` remains a compatibility re-export of this same `SdkClient` class and associated types, so both entry points preserve class identity. The package is a client for the documented v3 transport only: it does not restore the historical BridgeClient backend protocol, handshake/commands/SSE endpoints, or any direct host-control path.\n\n## Migration from the removed RPC mode\n\nThe retired `--mode rpc`, `rpc-ui`, and `bridge` modes are removed. The SDK v3\nWebSocket endpoint is now the canonical external control/query bus.\n\n| Retired RPC commands | SDK v3 control/query operations |\n| --- | --- |\n| `prompt`, `steer`, `follow_up`, `abort` | `turn.prompt`, `turn.steer`, `turn.follow_up`, `turn.abort` |\n| Model, thinking, queue, retry, and compaction controls | `model.*`, `thinking.*`, `queue.*`, `retry.*`, and `compaction.*` |\n| Session and transcript queries | `session.*`, `transcript.*`, `context.get`, and `session.stats` |\n| Workflow-gate response | `workflow.gate_answer` |\n\nSee the [RPC-to-SDK v3 parity audit](./sdk-rpc-parity-audit.md) for the full\nmatrix, partial equivalents, and evidence.\n\nFor a local non-WebSocket transport, run one of these commands:\n\n```sh\ngjc sdk serve --stdio\n```\n\n```sh\ngjc sdk serve --socket \n```\n\nIt relays the identical SDK v3 frames over stdio or a Unix socket. Socket\nclients send an authentication preface and the socket is mode `0600`; stdio is\none parent-owned connection.\n\nPython clients install the `gjc_sdk` package from `python/gjc-sdk`:\n\n```sh\npython -m pip install ./python/gjc-sdk\n```\n\nImport `SdkClient` with `from gjc_sdk import SdkClient`, then use\n`SdkClient.connect_ws`, `SdkClient.connect_socket`, or `SdkClient.connect_stdio`.\nThe client supplies `reply.token` for replies.\n\nPhase 2 still does **not** provide unattended negotiation, a cross-process\nreattach/registry, or a renderer-grade full event stream. No event-plane parity\nis claimed; see the audit's [ranked Phase-2 register](./sdk-rpc-parity-audit.md#ranked-phase-2-follow-up-register--not-implemented).\n\n## Architecture\n\n```\nGJC session (upstream) your client (anywhere)\n┌───────────────────────────────┐ ┌──────────────────────────┐\n│ ask-tool fires / agent idle │ action_needed │ Telegram / Discord / ... │\n│ → notifications core │ ─────────────▶ │ render + collect reply │\n│ ws://127.0.0.1: (+token) │ ◀───────────── │ │\n│ reply → resolve ask gate │ reply │ │\n└───────────────────────────────┘ └──────────────────────────┘\n```\n\n- **One endpoint per top-level session.** Each top-level session runs its own\n loopback WebSocket server. Subagents do not host endpoints. Upstream does not\n maintain a shared daemon, singleton, or chat-to-session registry;\n multiplexing many sessions into one integration is a client-side concern.\n- **Hosted by default.** SDK hosting is independent of notification\n configuration. Set `GJC_SDK_DISABLE=1` to opt out of hosting for a top-level\n session.\n- **Notification delivery is optional.** Configure and enable a managed\n notification adapter only when remote delivery is needed; the SDK endpoint\n remains available without one.\n- **Integrations are clients.** A client discovers endpoint files, connects to\n one or more WebSockets, renders `action_needed`, and sends `reply` messages.\n- **Zero upstream change.** New transports do not require changes to\n `crates/gjc-sdk` or the JSON protocol.\n- **tmux-agnostic.** The endpoint behaves identically with or without tmux.\n\n## Endpoint discovery\n\nA running session writes a discovery file at:\n\n```\n/.gjc/state/sdk/.json\n```\n\n(`.gjc/state/` is git-ignored.) Shape:\n\n```json\n{\n \"version\": 1,\n \"sessionId\": \"019edd41-...\",\n \"pid\": 12345,\n \"host\": \"127.0.0.1\",\n \"port\": 53124,\n \"url\": \"ws://127.0.0.1:53124\",\n \"token\": \"\",\n \"startedAt\": 1718760000000,\n \"updatedAt\": 1718760000000,\n \"stale\": false\n}\n```\n\n- The file is created `0700`/`0600` (unix) and written atomically.\n- The **token is in the file** because clients need it; never log it raw.\n Stale files (dead PID, past TTL, or explicitly marked) are cleaned up on the\n next start.\n\nConnect with the token as a query parameter:\n\n```\nws://127.0.0.1:/?token=\n```\n\nA wrong/missing token is rejected at the handshake with HTTP `401`.\n\n### Internal broker launch isolation\n\nWhen the SDK starts its default internal broker or session host from the published TypeScript source, GJC uses a fixed Bun launch policy: `--no-env-file`, a product-owned empty `bunfig.toml`, absolute product entrypoint paths, and no inherited `BUN_OPTIONS` or mutable compiled-mode markers. The broker bootstraps from the product SDK directory rather than the caller project; a session host still runs with the lifecycle-authorized workspace as its process cwd.\n\nThis boundary prevents a child from newly loading caller-cwd or user-global Bun preload/dotenv policy. It cannot determine how a value already present in the parent environment was originally loaded, so ordinary provider/GJC environment values remain inherited. Default internal children, including compiled self-spawns, remove inherited `BUN_OPTIONS` so parent eval/test/inspect/debug/runtime options cannot be replayed into a detached child. Compiled binaries otherwise retain their existing self-spawn command contract, corroborated by a dedicated embedded marker and exact anchored Bun virtual-filesystem identity. The explicit `GJC_SDK_SESSION_COMMAND` session-host override remains a trusted legacy operator boundary and is not parsed as a shell-safe general command API. There is no broker-command override.\n\nBroker and per-session discovery tokens remain in their authoritative private discovery files because clients need them. Launch errors, logs, and diagnostics redact those tokens and never include the child environment or isolation configuration contents.\n\n## Protocol\n\nJSON text frames. Field names are `camelCase`; the `type` discriminator is\n`snake_case`.\n\n### Server → client\n\n`action_needed` — something needs attention:\n\n```json\n{ \"type\": \"action_needed\", \"id\": \"act_9e31\", \"kind\": \"ask\",\n \"sessionId\": \"sess-1\", \"workflowGateId\": \"wg_run_stage_1\",\n \"question\": \"Proceed?\", \"options\": [\"Yes\", \"No\"], \"recommendedIndex\": 1 }\n```\n\n```json\n{ \"type\": \"action_needed\", \"id\": \"act_a42f\", \"kind\": \"ask\",\n \"sessionId\": \"sess-1\", \"question\": \"Choose a target\", \"options\": [\"A\", \"B\"] }\n```\n\n```json\n{ \"type\": \"action_needed\", \"id\": \"idle-sess-1-7\", \"kind\": \"idle\",\n \"sessionId\": \"sess-1\", \"summary\": \"finished refactor; awaiting next step\" }\n```\n\n- `id` is an opaque, transient presentation/action ID. It is the **only** authority accepted by generic `reply.id`; use it only with the current authenticated endpoint. It is not a durable workflow ID.\n- `workflowGateId?: string` is optional, additive SDK v3 correlation metadata, present only for the active presentation of a durable workflow gate. When present, it equals that gate's Q12 `gate_id`. Its public correlation key is `(sessionId, workflowGateId)` at the current authenticated endpoint; it never authorizes generic `reply`.\n- `kind: \"ask\"` is answerable in interactive/TUI and SDK workflow-gate sessions. `kind: \"idle\"` is notify-only and ephemeral (not replayed to clients that connect later). Ordinary asks and idle frames omit `workflowGateId`.\n- `recommendedIndex?: number` is optional, zero-based display metadata for `options`. Clients must validate that it is an in-range integer and ignore malformed values. Raw option labels and reply indices remain authoritative; never decorate submitted answers or infer a recommendation from position. The additive field is wire-compatible, but Rust consumers constructing the public `ActionNeeded` struct by literal must provide `recommended_index: None` when no recommendation exists.\n- This corrects the pre-v3 documentation invariant that `action_needed.id == gate_id`: they are deliberately different values. Clients must not preserve that invariant, infer a relationship from question/options/order, or retain private route, claim, receipt, epoch, token, or endpoint-generation maps.\n\n`action_resolved` — a pending action is now terminal and **non-repliable**:\n\n```json\n{ \"type\": \"action_resolved\", \"id\": \"act_9e31\", \"resolvedBy\": \"local\" }\n```\n\n`resolvedBy` is `local` (a local/direct control retired the presentation), `client` (a remote generic reply won), or `timeout`.\n\n`reply_rejected` — sent only to the client whose reply failed:\n\n```json\n{ \"type\": \"reply_rejected\", \"id\": \"act_9e31\", \"reason\": \"already_answered\" }\n```\n\nReasons: `already_answered`, `unknown_action`, `invalid_answer`,\n`resolver_unavailable`, `idempotency_conflict`, `unauthorized`.\n\nThe frames above are the minimal contract every client implements. Threaded\nclients (like the managed Telegram daemon) may also receive optional\nserver → client frames they can render or ignore: `identity_header` (one-time\nper-session repo/branch/machine header), `context_update` (last message, task,\ngoal, token usage, model, diff), `turn_stream` (live/finalized turn output),\n`image_attachment` (agent-produced images), `activity` (busy/idle, drives the\ntyping indicator), `inbound_ack` (delivery state of an injected user message),\n`session_closed` (endpoint teardown; threaded clients may delete/archive the\nremote conversation), `config_update` (current verbosity/redact), `hello`\n(server capability/version), and `pong`. A minimal client only needs\n`action_needed`, `action_resolved`, and `reply_rejected`.\n\n### Client → server\n\n`reply` — answer a pending `ask`:\n\n```json\n{ \"type\": \"reply\", \"id\": \"act_9e31\", \"answer\": 0, \"token\": \"\" }\n```\n\n`answer` accepts:\n\n- a number — zero-based option index (`0` = first option);\n- a string — an option label, or free text;\n- an object — `{ \"selected\": [0, \"Maybe\"], \"custom\": \"...\" }` for multi-select.\n\nOptional `idempotencyKey` makes retries safe: the same key + same body re-acks;\nthe same key + different body is rejected with `idempotency_conflict`.\n\nThreaded clients may also send optional client → server frames: `user_message`\n(inject/steer a turn with free text), `config_command` (toggle verbosity/redact\nin-thread), `hello` (capability/version), and `ping`. A minimal client only\nneeds `reply`.\n\n## Model catalog query (Q10)\n\nThe SDK exposes the model catalog through the paged Q10 registry query. `Q10`,\n`models.list/current`, `models.list`, and `models.current` are exact aliases:\neach returns the same paged registry array, not a current-model singleton or a\nfiltered list. Continue using the returned cursor until `page.complete` is\ntrue.\n\nEach row preserves the five legacy fields (`provider`, `id`, `name`,\n`contextWindow`, and `maxTokens`) and additively includes `reasoning`,\n`thinking`, and `current`. `currentThinkingLevel` appears only on the current\nrow when the live session has a thinking level. The exported DTO types are\n`Q10Model`, `Q10ThinkingCapabilities`, `Q10ThinkingEffort`,\n`Q10SettableThinkingLevel`, `Q10CurrentThinkingLevel`, and\n`Q10ThinkingMode`, all from `@gajae-code/coding-agent/sdk`; there is no public\n`/sdk/models` subpath.\n\n```json\n{\n \"provider\": \"runtime-provider\",\n \"id\": \"reasoning-model\",\n \"name\": \"Reasoning Model\",\n \"contextWindow\": 128000,\n \"maxTokens\": 8192,\n \"reasoning\": true,\n \"thinking\": {\n \"validLevels\": [\"off\", \"minimal\", \"low\", \"medium\", \"high\"],\n \"minLevel\": \"minimal\",\n \"maxLevel\": \"high\",\n \"mode\": \"effort\",\n \"defaultLevel\": \"low\"\n },\n \"current\": true,\n \"currentThinkingLevel\": \"high\"\n}\n```\n\n`thinking.validLevels` is always present and starts with `\"off\"`; it is the\ncanonical menu for `model.set` and never contains `\"inherit\"`. For a\nnon-reasoning model it is exactly `[\"off\"]`. Successful reasoning rows always\ninclude `minLevel`, `maxLevel`, and `mode`; only `defaultLevel` and raw `levels`\nare optional. Raw `levels` deliberately keeps its descriptor order and\nduplicates, while `validLevels` is the canonical, deduplicated menu clients\nshould render. `\"inherit\"` is a current-state readback value only and is rejected\nas a `model.set` input.\n\nMalformed reasoning descriptors are not client-recoverable catalog data. The\nquery returns the SDK's safe `internal` error rather than exposing a partially\nformed row or descriptor details.\n\n## Prompt acceptance, termination, and reconciliation (Q26)\n\n`runtime.capabilities.promptTerminalOutcomeVersion` is `1` when this contract is available. Its normalized TypeScript terminal outcome is:\n\n```ts\ntype SdkPromptTerminalOutcome =\n\t| {\n\t\t\tkind: \"stopped\";\n\t\t\treason: \"end_turn\" | \"max_tokens\" | \"max_turn_requests\" | \"refusal\" | \"cancelled\";\n\t\t\tprovenance: \"agent\" | \"client_cancel\";\n\t }\n\t| {\n\t\t\tkind: \"failed\";\n\t\t\tcode: \"prompt_failed\" | \"prompt_deadline_exceeded\";\n\t\t\tmessage: string;\n\t\t\tprovenance: \"agent_failed\" | \"deadline\";\n\t };\n```\n\n`turn.prompt` returns `{ accepted: true, commandId, turnId, clientRef? }` only after\nits asynchronous preflight accepts the prompt. That receipt is a durable,\n**non-terminal pending claim**, not a process-durable terminal result. The SDK\nlater finalizes that claim with exactly one `SdkPromptTerminalOutcome`; cleanup\nmay follow only after the claim is durable.\n\nThe authoritative public reconciliation query is `Q26` /\n`turn.prompt_status`, scoped to the same live session runtime. Its `outcome`\nfield is exposed only after finalization. A pending claim is never represented\nor exposed as a terminal outcome.\n\nCallers that must recover from a lost acknowledgement should assign one fresh\n`clientRef` (a trimmed, non-empty string of at most 128 characters) to each logical\nprompt. Reconnect to the same session endpoint and query with exactly one selector:\n\n```json\n{ \"type\": \"query_request\", \"query\": \"turn.prompt_status\",\n \"input\": { \"clientRef\": \"request-018f\" } }\n```\n\nor:\n\n```json\n{ \"type\": \"query_request\", \"query\": \"turn.prompt_status\",\n \"input\": { \"commandId\": \"command-id\", \"turnId\": \"turn-id\" } }\n```\n\nThe result status is `accepted`, `in_flight`, `terminal_ok`, `failed`, or\n`unknown`. Known records include `acceptedAt`; in-flight and terminal records add\n`startedAt` and/or `terminalAt`; finalized records include `outcome`; failed records\nalso include a bounded sanitized `error.code` and `error.message`. Cursors, partial\ngenerated-ID pairs, mixed selectors, and extra selector fields are rejected.\n\nCorrelated `agent_end` and `agent_failed` frames carry the same finalized\n`outcome`. Clients must correlate those frames and Q26 by the prompt identifiers,\nnot infer terminality from stream activity or an earlier pending claim.\n\nReconciliation state survives client disconnect/reconnect. With the session-private\ndurable store (`.sdk-reconciliation/`), accepted and terminal prompt records also\nsurvive **GJC session-process restart** for the same session identity within\ncapacity/TTL, subject to crash-consistent fsync. A non-terminal prompt record at\nrestart finalizes its pending outcome; if that claim is absent, it finalizes as\n`{ kind: \"failed\", code: \"prompt_failed\", ... }`. This prompt-specific recovery\ndoes not apply to skill records: active `skill.invoke` records retain\n`error.code = process_restart` because their reconciliation is incomplete, not\nproof of a skill failure. Eviction or absence still returns honest `unknown`; that\nmeans the prior outcome is unknowable, not that execution did not occur. Active\nrecords are capped at 128 per kind and are never aged into terminal. Terminal\nrecords are retained for 15 minutes, capped at 256 per kind, and evicted\noldest-terminal first.\n\n`turn.prompt` remains ordered and non-idempotent. Its envelope `idempotencyKey`\ndoes not replay a response or produce `idempotency_conflict`. A retained duplicate\n`clientRef` fails before execution with `client_ref_conflict`, but callers must not\nreuse a `clientRef` as a retry mechanism: after eviction the same value can identify\na new prompt while the old outcome remains unknown.\n\n`turn.abort` returns a typed disposition. A caller that does not own the target\nreceives `resource_gone`; it must not treat that result as cancellation of another\nprompt.\n\n`sdk.promptDeadlineMs` defaults to `1_800_000`. It accepts only safe integers in\n`[60_000, 86_400_000]`; there is no disable value. The SDK snapshots the setting\nwhen the prompt is durably accepted. Terminalization then has a fixed `10_000` ms\ngrace period, which is not configurable. A controlled terminal failure reaches ACP\nas JSON-RPC `-32603` with `data.code` of `prompt_failed` or\n`prompt_deadline_exceeded`.\n\n## Skill invoke reconciliation (Q28)\n\n`skill.invoke` accepts optional `clientRef` and returns an early accepted receipt\n`{ accepted: true, commandId, turnId, clientRef?, name, path, lineCount?, args? }` after\ndurable/preflight accept (SDK control path), not after skill completion. Query prior\nstatus with `Q28` / `skill.invoke_status` using the same selectors as Q26. Kind-scoped\nindexes mean prompt and skill `clientRef` values never collide. Skill records use the\nsame capacity/TTL limits, but an active skill record at restart settles with\n`error.code = process_restart`.\n\n## Model profile discovery and validation (Q27)\n\n`Q27` / `models.profiles.list` pages the effective model-profile catalog owned by\nthe attached session. Rows are sorted by exact ID and contain only:\n\n```json\n{ \"id\": \"codex-medium\", \"displayName\": \"codex-medium\", \"source\": \"builtin\" }\n```\n\n`source` is `builtin` or `configured`. Profiles from `/models.yml`\noverride built-ins with the same exact ID, including their display label. Profile\nIDs are not trimmed, case-folded, sanitized, or restricted to safe-token names;\ndiscover the exact ID and send it unchanged. The retired `codex-standard` alias is\nfallback-only and never shadows a configured profile with that exact ID.\n\nQ27 uses retained-revision, connection-bound pagination. Continue an issued cursor\nto finish its stable snapshot; a fresh cursorless query observes the current\nregistry. The query accepts no root, path, or selector input. An invalid or\nunreadable `models.yml` fails closed with `model_profile_registry_error` rather\nthan returning a plausible built-ins-only catalog.\n\nBroker `session.create`, `session.fork`, and `session.resume` validate `modelPreset`\nbefore spawning against the same `/models.yml` authority\nthat the child receives through `GJC_AGENT_DIR` / `GJC_CODING_AGENT_DIR`. Unknown\nIDs return `unknown_model_profile`. Both typed errors include bounded `details`\nwith `requestedProfile` where applicable, whole exact `availableProfiles` entries\nthat fit the detail budget, and `discoveryQuery: \"models.profiles.list\"`. The\ndiscovery pointer is authoritative when the bounded error cannot include every ID.\n\n## Answer semantics\n\nA remote reply answers a pending ask in every session state:\n\n- **Interactive / TUI mode:** the ask tool races the local selector against the\n remote reply (first valid answer wins). A client submits generic `reply` using\n the active presentation `id`; a local answer emits `action_resolved`\n (`resolvedBy: \"local\"`) and that presentation becomes non-repliable.\n- **SDK workflow gate:** generic `reply` still uses the active presentation\n `id`, never `workflowGateId`. The resolved gate drives the session the same\n way a local answer would.\n\nA session has at most one active answerable presentation. Interactive asks and durable workflow gates are serialized; further Q12 gates wait in a durable queue. A same-server reconnect replays the active `action_needed` with the same presentation ID. After a process restart, previously pending or accepted-but-unadvanced records are quarantined diagnostics and a reconstructed workflow remints fresh durable gate and presentation IDs. Terminal, stale, and reissued action IDs never regain authority.\n\nGeneric and direct controls may race. Once the native generic claim is acquired, it wins; a direct control that atomically retires the exact unclaimed active presentation first wins instead. Losing direct controls fail without advancing the gate, and losing generic replies are stale/non-repliable. Clients must not retry by matching text, durable IDs, or presentation history; they must fail closed rather than guess when session or action identity is unsafe or ambiguous.\n\n### Durable workflow controls and Q12\n\n`workflow.gate_answer` and `workflow.plan_approve` operate on the durable\nQ12 `gate_id`, not `action_needed.id`. Both accept optional\n`expectedSessionId`; clients should always send the `sessionId` observed from\nthe current authenticated endpoint:\n\n```json\n{ \"type\": \"control_request\", \"operation\": \"workflow.gate_answer\",\n \"input\": { \"id\": \"wg_run_stage_1\", \"response\": \"approve\", \"expectedSessionId\": \"sess-1\" } }\n```\n\n```json\n{ \"type\": \"control_request\", \"operation\": \"workflow.plan_approve\",\n \"input\": { \"id\": \"wg_run_stage_1\", \"choice\": \"approve\", \"expectedSessionId\": \"sess-1\" } }\n```\n\n`expectedSessionId` omission remains accepted and audited for the entire SDK v3 line so deployed v3 control clients continue to work; new clients must send it now. It cannot become mandatory, or be removed from the controls, before SDK v4 and at least one full published deprecation release/window with deployed-client notice. A supplied session mismatch is rejected before the gate resolver runs. Neither control accepts a presentation ID, remaps an old ID to a reminted gate, or uses heuristic matching.\n\nQ12 (`workflow.gates.list`) exposes durable query records and additive SDK v3 diagnostics. A pending record preserves its workflow fields including `gate_id` and adds `id: \"pending:\"` and `tag: \"pending\"`. A restart quarantine diagnostic uses `id: \"diagnostic:\"`, `tag: \"quarantined\"`, and optional `lifecycle` containing `state: \"quarantined\"`, its restart reason, `quarantinedAt`, and an optional `supersededByGateId` after a remint. Diagnostics are query-only: they cannot be routed, answered, or promoted. Treat Q12 as the durable status surface, not as generic-reply authority.\n\n### Coordinator MCP question pull loop\n\nThe Coordinator MCP bridge is a separate, public-safe pull surface for external coordinators. `gjc_coordinator_list_questions` requires `session_id` and reconciles pending `workflow.gates.list` rows on every call, returning bounded public `questions`, `diagnostics`, and `reconciliation`. It accepts `status: \"pending\"`; `status: \"open\"` remains a compatibility alias. Multiple pending rows can be returned. A pending row carries its safe question shape, public option ids, and `answer_binding`, never raw/private gate payloads or values.\n\n`gjc_coordinator_submit_question_answer` requires `session_id`, `turn_id`, `question_id`, `answer_binding`, `answer`, `idempotency_key`, and `allow_mutation: true`. It re-lists/revalidates after restart and resolves through `workflow.gate_answer`, not generic `ask.answer`. An incomplete reconciliation returns `terminal_uncertain`; stale, terminal, missing, or ownership-mismatched rows cannot be answered. Re-list after restart rather than retaining old identifiers. An identical retry with the same idempotency key replays the accepted result; conflicting reuse returns `idempotency_conflict`.\n\nThis contract does not change #2549/#2551 or unattended plain-CLI behavior.\n\n### Rust and N-API compatibility\n\nThe Rust `ActionNeeded`, `ServerMessage`, and `register_ask` APIs remain\nlegacy-compatible and uncorrelated. Correlation is available through additive\nRust workflow-frame decoding/current-reader APIs and the workflow registration\npath; consumers that need correlation must opt in explicitly. N-API likewise\nretains `registerAsk`, and adds `registerWorkflowGateAsk` for a correlated wire\nframe plus `registerArbitratedAsk` and `retireIfUnclaimed` for in-process\npresentation arbitration. The arbitration lease and all claim/receipt/epoch\nstate remain private: these APIs do not create a public authority value.\n\n### Runtime and native addon release pairing\n\nThe `@gajae-code/coding-agent` runtime and `@gajae-code/natives` native addon ship from the same source release at exact matching package versions. The native loader requires the matching version sentinel; mixed native/runtime versions are unsupported and must not claim SDK compatibility.\n\n## Minimal client example\n\n```js\nimport { readFileSync } from \"node:fs\";\nimport WebSocket from \"ws\";\n\nconst { url, token } = JSON.parse(\n readFileSync(`.gjc/state/sdk/${sessionId}.json`, \"utf8\"),\n);\n\nconst ws = new WebSocket(`${url}/?token=${encodeURIComponent(token)}`);\n\nws.on(\"message\", (data) => {\n const msg = JSON.parse(data.toString());\n if (msg.type === \"action_needed\" && msg.kind === \"ask\") {\n // present msg.question / msg.options to the human, then:\n ws.send(JSON.stringify({ type: \"reply\", id: msg.id, answer: 0, token }));\n } else if (msg.type === \"action_resolved\") {\n // mark this action as no longer answerable in your UI\n } else if (msg.type === \"reply_rejected\") {\n // e.g. reason === \"already_answered\" → the ask was answered elsewhere\n }\n});\n```\n\nSwap `ws` for a Telegram bot's long-poll loop, a Discord gateway client, or a\nSlack socket-mode app — the contract above is all you implement.\n\n## Fallback chains\n\nModel-role selectors may be ordered fallback chains; see [Fallback chains](./models.md#fallback-chains) for configuration and retry-budget details. Resolution-time skips do not consume attempts. When a request-time retry advances to another eligible entry, the selected default fallback remains sticky for later prompts in that session until an explicit model selection or a chain reset changes it.\n\n`model_fallback_switched { eventId, from, to, reason, role, scope, activeIndex, chainLength, attemptsUsed }` is the canonical session lifecycle event for every real fallback-model switch. It replaces the legacy `retry_fallback_applied` / `retry_fallback_succeeded` event names. Embedding clients can subscribe to this session event; generic WebSocket clients should use only the protocol frames documented above and any adapter-specific status updates they support.\n\n\n## Managed session-directory adapter guidance\n\nSDK adapters that need to inspect saved sessions must import only the supported public surface from `@gajae-code/coding-agent/sdk`:\n\n```ts\nimport {\n SESSION_DIRECTORY_API_VERSION,\n listManagedSessionCandidates,\n resolveManagedSessionScope,\n} from \"@gajae-code/coding-agent/sdk\";\n\nif (SESSION_DIRECTORY_API_VERSION !== 1) throw new Error(\"Unsupported session-directory API\");\nconst resolved = await resolveManagedSessionScope({ cwd: process.cwd() });\nif (resolved.kind === \"resolved\") {\n const listing = await listManagedSessionCandidates({ scope: resolved.scope });\n // Consume only listing.kind === \"complete\" and its owned candidates.\n}\n```\n\nThis is a readonly resolver/listing contract. Do not import `@gajae-code/coding-agent/session/internal/*`, derive `v2-…` names, write bindings, or implement migration/cleanup in an adapter; private internal subpaths are intentionally unavailable from the packaged module. Treat `network_unsupported`, binding/security errors, incomplete listings, invalid candidates, and foreign candidates as non-authoritative results rather than retrying with a guessed path.\n\nThe resolver uses canonical native identity: supported POSIX and Windows local aliases can designate one scope, while UNC/network workspaces are unsupported. Scope digests are collision-resistant identifiers, not injective aliases, credentials, or authentication. The owner-only checks protect managed local storage paths but do not authenticate an adapter or make hostile concurrent filesystem races safe. Adapters that need mutations must use the higher-level lifecycle/session APIs rather than the readonly directory API.\n## Managed notification adapters\n\nGJC ships managed SDK-client adapters for Telegram, Discord, and Slack. They use\none local SDK endpoint per session; the adapters do not change the wire protocol,\nkeep endpoint credentials in provider state, or expose a remote shell.\n\nThe recommended interactive path is `/settings` → **Notifications**. It owns\nsetup, health, test, recovery, reconnect, local enablement, and Telegram\nremoval without exposing stored credentials.\n`gjc notify setup` remains the authoritative CLI fallback for headless and\nautomated environments.\n\nNotification credentials and `notifications.*` settings are global-only.\nProject notification keys are\nignored and runtime notification overrides are rejected. Telegram pairing\nrevalidates the complete bot-token/chat identity immediately before polling and\nagain before activation. A foreign or unknown owner is never killed, reloaded, or taken over;\nsetup fails closed without saving or exposing the raw token.\n\nConfiguration completeness, provider-local quarantine, durable desired intent, effective enablement, runtime readiness, and delivery outcomes are separate contracts. The global `notifications.enabled` master never erases provider credentials or desired flags. `/settings` edits secrets through explicit `keep`, `replace`, or `remove` actions, commits only the selected provider in one CAS batch, and reports post-commit observer or activation failures without pretending the durable save rolled back. Malformed provider-local values are quarantined for explicit repair while safe sibling providers remain usable; malformed global notification structure remains fail-closed.\n\n`GJC_NOTIFICATIONS=0` suppresses only automatic generic current-session admission. Explicit `/notify on` can opt the current session back in without mutating durable provider state, and direct provider APIs remain governed by provider effectiveness and their own runtime readiness. If Telegram ownership is proven foreign while Discord or Slack is effective, GJC publishes the chat daemon endpoint under the isolated `.gjc/state/chat/sdk/` discovery path; the blocked Telegram scanner never receives the shared endpoint token.\n\n- [Telegram notification onboarding](./telegram-onboarding.md) documents\n `gjc notify setup` and private-chat pairing.\n- [Discord notification onboarding](./discord-onboarding.md) documents\n `gjc notify setup discord`, required configuration, thread lifecycle, and\n least-privilege permissions.\n- [Slack notification onboarding](./slack-onboarding.md) documents\n `gjc notify setup slack`, Socket Mode configuration, immediate envelope ack,\n and thread lifecycle.\n\n`gjc notify status` reports provider completeness, repair/quarantine state, desired intent, effective enablement, and masked tokens. Destination identifiers remain visible and may be sensitive. The Discord and Slack setup commands are non-interactive and require their documented identifier and token flags; supply secrets through an approved local mechanism, not examples, committed files, shell history, logs, or chat. `gjc notify health --provider --probe` performs a provider-owned REST diagnostic even when complete credentials are intentionally inactive, while `gjc notify test --provider ` additionally requires effective enablement and runtime readiness.\n\nThe daemon/session engine is shared. Session discovery, WebSocket protocol,\nredaction decisions, rate-limit pooling, reply routing, singleton ownership, and\nlifecycle control are not reimplemented by each chat surface. Telegram, Discord,\nand Slack adapters are thin presentation layers: they render internal notification\nevents into transport payloads and map transport interactions back to `{sessionId,\nactionId,answer}` replies.\n\nDiscord maps a session to an archiveable thread; resume unarchives it or creates\na replacement, and stale/superseded thread input fails closed. Slack maps a\nsession to an immutable root thread; resume creates a new root, acknowledges all\nSocket Mode envelopes immediately, and does not persist a Socket Mode cursor.\n\nThe Discord and Slack acceptance suites use fake providers only. They exercise\nprovider failure, reconciliation, restart, dedupe, lifecycle, and reconnect paths\nwithout live credentials or live-provider end-to-end tests.\n\n## Managed Telegram daemon (bundled reference client)\n\nGJC also ships a managed Telegram reference client for the common phone-notify\nworkflow. It remains a client of the generic SDK: it scans session discovery\nfiles, opens each session WebSocket, and routes Telegram replies back to the\nmatching endpoint. Run `gjc notify setup` once to complete Telegram's interactive\nprivate-chat pairing flow.\n\nFor Telegram forum topics, the daemon deletes the per-session topic when the local\nnotification endpoint shuts down, so it disappears from the topic list. A resumed\nsession creates a fresh topic before sending again. The bot must be allowed to\ndelete messages in that chat; without that permission, deletion is best-effort and\ndelivery continues.\n\n### Singleton poller and trust model\n\nTelegram `getUpdates` allows only one active long-poll owner per bot token. The\nmanaged daemon enforces **one bot token = one getUpdates poller** with a local\nlock/state file under the agent directory. New sessions attach to the existing\nfresh daemon owner instead of starting another poller, preventing Telegram 409\nconflicts.\n\nThe trust model is intentionally strict:\n\n- setup pairs exactly one private Telegram chat;\n- runtime accepts updates only from that paired chat id;\n- groups, supergroups, channels, and unpaired users never receive session names,\n action ids, pending status, or configuration hints;\n- daemon state stores a token fingerprint, not the raw bot token.\n\n### Routing in private-chat topics\n\nThe paired private chat prefers per-session Telegram topics (Threaded Mode). The\ndaemon tags messages by session, stores compact callback aliases for inline\nbuttons, and routes replies back to the exact session/action. A forum-enabled\nsupergroup is no longer required: when the bot owner enables Threaded Mode in\n@BotFather, the daemon creates one topic per session in the paired private chat.\nGJC cannot enable Threaded Mode through the Bot API; setup only verifies the\ncapability and guides the manual BotFather toggle.\n\nIf BotFather's per-bot **Bot Settings** menu does not show **Threads Settings**\nor **Threaded Mode**, the supported fallback is the normal private-chat pairing.\nSetup can be saved as `threaded=unverified`/`threaded=unknown`, and the daemon\nstill tries topics when Telegram allows them. When `createForumTopic` is refused,\nthe daemon does not drop the send: it routes the notification to the normal\n(flat) paired private chat and posts a one-time nudge: `Flat Telegram private chat\nsupports outbound notifications and inline ask buttons only. Enable Threaded Mode\nin @BotFather > Bot Settings > Threads Settings for free-text replies and session\ncommands.` Pairing is private-only, so flat delivery stays within the user's own\nprivate DM.\n\nSupported reply paths:\n\n- tap an inline button on an ask notification;\n- reply inside the session's thread/topic (replies are thread-native; the\n topic identifies the session, so no session tag is needed).\n\nIn threaded mode the user can also adjust per-session behaviour with in-thread\nconfig commands: `/verbose` (per-tool-turn assistant text), `/lean` (settled\nassistant answer at idle plus immediate ask lead-ins; the default),\n`/verbosity `, and `/redact `. The legacy\n`/answer ` command is removed — replies are routed by the\ntopic they arrive in.\n\nFlat fallback keeps outbound notifications and inline-button answers working, but\nplain free-text never guesses from the global pending-ask set. Free-text replies\nand `/verbose`/`/lean`/`/verbosity`/`/redact` commands are thread-native and\nrequire Threaded Mode/topic routing. Enable Threaded Mode in @BotFather > Bot\nSettings > Threads Settings when you need free-text replies or session commands.\nDo not pair a group, supergroup, or channel to work around a missing BotFather\nmenu; the bundled setup flow is\nprivate-chat only, and non-private chat ids remain fail-closed to avoid session\ndata leaks.\n\nUnknown, expired, or restart-unvalidated callback aliases fail closed: the daemon\nsends guidance and does not guess a target session or action.\n\n### Discord and Slack setup\n\nDiscord and Slack use the same internal notification events and reply protocol as\nTelegram. Store only runtime credentials in local GJC settings or environment;\nnever paste bot tokens, webhook URLs, transcripts, prompts, host paths, or raw logs\ninto docs, tests, issues, or PR comments.\n\nConfiguration keys:\n\n```yaml\nnotifications:\n enabled: true\n discord:\n botToken: \"\"\n applicationId: \"\"\n guildId: \"\"\n parentChannelId: \"\"\n slack:\n botToken: \"\"\n appToken: \"\"\n workspaceId: \"\"\n channelId: \"\"\n authorizedUserId: \"\"\n redact: true\n```\n\nThe bundled adapters intentionally render public-safe message bodies and return\nroute metadata only for pending internal actions. They do not own polling,\nsession scans, daemon locks, rate limits, or SDK lifecycle. Production transport\nsenders should consume the adapter payloads and keep all credential-bearing HTTP\nor gateway details outside logged payloads.\n### Redaction\n\n`notifications.redact` strips sensitive content before remote delivery, but\n**asks are exempt**: an ask is an interactive prompt the human must read and\nanswer remotely, so its `question` and `options` are always sent unredacted\n(otherwise it would be unanswerable). When redaction is enabled, `idle`\nsummaries are removed and streamed content frames (`turn_stream`,\n`context_update`, `image_attachment`) are suppressed at their emit sites. When\nredaction is disabled, all content is delivered unchanged.\n\n### Local `/notify`\n\nInside a GJC session, `/notify` controls the current session only:\n\n- `/notify status` reports enabled/disabled state, daemon observation when known,\n and redaction state without printing secrets;\n- `/notify off` disables the current session's notification endpoint and removes\n its discovery record without mutating global Settings;\n- `/notify on` re-enables the current session when global setup is complete and\n `GJC_NOTIFICATIONS=0` is not forcing opt-out.\n\n### Manual Telegram CLI is for debugging\n\n`packages/coding-agent/src/sdk/bus/telegram-cli.ts` remains as a manual\nreference/debug client and template for other integrations. It is not the primary\nTelegram UX.\n\n```sh\nbun run packages/coding-agent/src/sdk/bus/telegram-cli.ts --bot-token \"$BOT_TOKEN\"\n```\n\nBy default it refuses to start when a fresh managed daemon already owns the same\nbot token for the same paired chat, because a second poller will cause Telegram\n409 conflicts. Use `--force` only for deliberate debugging when you have stopped\nor intentionally want to override the daemon guard.\n## Two client surfaces: per-session vs daemon-owned lifecycle control\n\nThe SDK now exposes **two distinct surfaces**. Do not confuse them:\n\n1. **Per-session notification clients (the normal, documented contract above).**\n A client discovers `/.gjc/state/sdk/.json`, connects\n to that session's loopback WebSocket, and handles `action_needed`,\n `action_resolved`, `reply_rejected`, and the optional threaded frames. This is\n all an ordinary integration (Telegram, Discord, Slack, mobile, local tools)\n needs. It requires **zero** upstream changes.\n\n2. **The daemon-owned session *lifecycle* control endpoint (privileged).**\n A separate, **session-independent**, loopback-only, authenticated control\n endpoint that accepts `session_create` / `session_close` / `session_resume`\n frames. It exists because creating a session cannot use a per-session socket\n (none exists before the session does). It is **not** part of the normal\n integration contract: ordinary clients never implement it. Only the bundled,\n trusted daemon (e.g. the managed Telegram daemon) speaks it.\n\n### Lifecycle control endpoint\n\n- **Discovery:** `/notifications/control.json` (daemon-owned, mode\n `0600`), distinct from per-session endpoint files. It carries only non-secret\n endpoint metadata (url/host/port/pid/owner). The control token is held **in\n memory** by the daemon (the sole client) and is **never** written to disk.\n- **Auth and routing:** the loopback SDK broker requires\n `?token=` (HTTP `401` otherwise) and re-checks every\n lifecycle frame's `token` (`unauthorized` on mismatch). It routes accepted\n requests through the canonical SDK lifecycle operation.\n- **Frames:** `session_create` (target `existing_path` | `worktree` |\n `plain_dir`), `session_close` (hard-kill, history preserved, recoverable),\n `session_resume` (reattach if alive, else cold-restart from history); responses\n `session_create_response` / `session_close_response` / `session_resume_response`\n / `session_lifecycle_error`. The protocol also defines a replayable\n `session_ready` per-session frame for readiness-gated creates; the current MVP\n daemon replies once the tmux launch is requested (see the phone guide) rather\n than waiting on it. Inline prompt text (`-- `) is rejected in the MVP.\n\n### Trust model and hardening (daemon side)\n\nThe control endpoint trusts the configured paired chat for any path (an accepted\nrisk). It is hardened around that boundary:\n\n- **Strict paired-chat gating** — non-paired chats are rejected *before* any path\n parsing, filesystem, or process action.\n- **Durable idempotency** — a locked, atomic, fsynced ledger keyed by\n `chatId:updateId` + request hash (`telegram-lifecycle-idempotency.json`).\n Duplicate updates never repeat side effects, including across daemon restart; a\n duplicate while in-progress reports pending (never a second spawn); a same id\n with a different body is `duplicate_conflict`; an effect failure is recorded\n `terminal_uncertain` (never auto-respawned).\n- **Per-chat create rate limit.**\n- **Audit log** — append-only `telegram-lifecycle-audit.jsonl` (`0600`) recording\n every accept/reject/duplicate/rate-limit/spawn/success/failure. Raw control\n tokens and raw prompts are never logged (prompt hash + byte length only).\n- **Inline prompts rejected (MVP)** — `session_create` with `-- ` text is\n rejected with usage; no prompt is ever placed in argv, audit, or responses. (A\n redacted prompt-ref flow is reserved for a future revision.)\n- **GJC-managed-only close** — force-close re-reads the exact `@gjc-profile`\n immediately before kill and requires the `@gjc-session-id` (and optional\n `@gjc-session-state-file`) tag to match; it never touches non-GJC tmux.\n- **Recent-activity picker** — sessions are ranked by history-file mtime and\n enriched with terminal breadcrumbs so the operator picks a recent repo/session\n instead of typing raw paths. Ambiguous resumes fail closed with candidates.\n### Phone test guide (create / close / resume from Telegram)\n\nEnd-to-end manual check once `gjc notify setup` has paired your private chat:\n\n1. **Pair + start.** Run `gjc notify setup` (BotFather token, DM the bot to pair).\n Start any GJC session with notifications enabled so the daemon owner is\n running (`gjc launch` in a repo, or `GJC_NOTIFICATIONS=1`). The owner starts\n the loopback control endpoint and accepts `/session_*` while running; with zero\n active sessions it still idle-exits after the inactivity timeout.\n2. **Create.** From your paired chat, pick `/session_create` from the Telegram\n command menu or send `/session_create path ` (or\n `/session_create worktree `, or `/session_create dir `).\n ``, ``, and `` may use `~`/`~/...` for your own home\n directory; named-user forms such as `~alice/repo` are rejected. The bot replies\n once the tmux launch is requested; the session shows up in `/session_recent`\n once it is ready. (Inline prompts via `-- ` are rejected for now with\n usage text.)\n3. **List.** `/session_recent` shows recent sessions (most-recent first) to copy\n an id from.\n4. **Close.** `/session_close ` hard-kills the GJC-managed session\n (history is preserved); the bot confirms.\n5. **Resume.** `/session_resume ` reattaches if it is still\n alive, otherwise cold-restarts it from saved history. An ambiguous prefix\n replies with the matching candidates instead of guessing.\n\nCommands are accepted **only** from the paired chat; **create** is rate-limited,\nand all lifecycle commands are idempotent per Telegram update id and audited (no\ntokens or prompts are logged).\nFor an automated proof of the wire path without a real bot, see\n`packages/coding-agent/scripts/g011-daemon-path-smoke.ts` (real native control\nendpoint + loopback WebSocket).\n", + "sdk.md": "# Gajae-Code SDK\n\nFor embedding GJC in-process, see [the embedding SDK guide](./sdk-embedding.md).\nFor a beginner-friendly application development guide (recipes, customization, and surface selection), see [Building applications on the SDK](./sdk-app-guide.md).\n\n

\n \"Gajae\n

\n\nA small, transport-agnostic SDK for receiving **action-needed** signals from a\nGJC session and sending **replies** back without scraping the terminal.\n\nThe stable contract is deliberately generic: every top-level running session\nhosts one loopback WebSocket endpoint by default, and integrations are\nuser-written clients that connect to that endpoint. Telegram, Discord, Slack,\nmobile apps, and local tools all use the same JSON protocol. No upstream Rust,\nN-API, or wire-protocol change is required for a new integration.\n\n> Status: the Rust core (`crates/gjc-sdk`) provides the wire protocol, action\n> lifecycle, loopback WebSocket server, and endpoint discovery file. The bundled\n> Telegram daemon is a reference client layered on top of this SDK; it is not the\n> upstream topology.\n\n## TypeScript transport client\n\nInstall the standalone transport-only client when connecting to the v3 SDK WebSocket endpoint from TypeScript:\n\n```bash\nbun add @gajae-code/bridge-client\n```\n\n```ts\nimport { SdkClient } from \"@gajae-code/bridge-client\";\n```\n\n`@gajae-code/coding-agent/sdk` remains a compatibility re-export of this same `SdkClient` class and associated types, so both entry points preserve class identity. The package is a client for the documented v3 transport only: it does not restore the historical BridgeClient backend protocol, handshake/commands/SSE endpoints, or any direct host-control path.\n\n## Migration from the removed RPC mode\n\nThe retired `--mode rpc`, `rpc-ui`, and `bridge` modes are removed. The SDK v3\nWebSocket endpoint is now the canonical external control/query bus.\n\n| Retired RPC commands | SDK v3 control/query operations |\n| --- | --- |\n| `prompt`, `steer`, `follow_up`, `abort` | `turn.prompt`, `turn.steer`, `turn.follow_up`, `turn.abort` |\n| Model, thinking, queue, retry, and compaction controls | `model.*`, `thinking.*`, `queue.*`, `retry.*`, and `compaction.*` |\n| Session and transcript queries | `session.*`, `transcript.*`, `context.get`, and `session.stats` |\n| Workflow-gate response | `workflow.gate_answer` |\n\nSee the [RPC-to-SDK v3 parity audit](./sdk-rpc-parity-audit.md) for the full\nmatrix, partial equivalents, and evidence.\n\nFor a local non-WebSocket transport, run one of these commands:\n\n```sh\ngjc sdk serve --stdio\n```\n\n```sh\ngjc sdk serve --socket \n```\n\nIt relays the identical SDK v3 frames over stdio or a Unix socket. Socket\nclients send an authentication preface and the socket is mode `0600`; stdio is\none parent-owned connection.\n\nPython clients install the `gjc_sdk` package from `python/gjc-sdk`:\n\n```sh\npython -m pip install ./python/gjc-sdk\n```\n\nImport `SdkClient` with `from gjc_sdk import SdkClient`, then use\n`SdkClient.connect_ws`, `SdkClient.connect_socket`, or `SdkClient.connect_stdio`.\nThe client supplies `reply.token` for replies.\n\nPhase 2 still does **not** provide unattended negotiation, a cross-process\nreattach/registry, or a renderer-grade full event stream. No event-plane parity\nis claimed; see the audit's [ranked Phase-2 register](./sdk-rpc-parity-audit.md#ranked-phase-2-follow-up-register--not-implemented).\n\n## Architecture\n\n```\nGJC session (upstream) your client (anywhere)\n┌───────────────────────────────┐ ┌──────────────────────────┐\n│ ask-tool fires / agent idle │ action_needed │ Telegram / Discord / ... │\n│ → notifications core │ ─────────────▶ │ render + collect reply │\n│ ws://127.0.0.1: (+token) │ ◀───────────── │ │\n│ reply → resolve ask gate │ reply │ │\n└───────────────────────────────┘ └──────────────────────────┘\n```\n\n- **One endpoint per top-level session.** Each top-level session runs its own\n loopback WebSocket server. Subagents do not host endpoints. Upstream does not\n maintain a shared daemon, singleton, or chat-to-session registry;\n multiplexing many sessions into one integration is a client-side concern.\n- **Hosted by default.** SDK hosting is independent of notification\n configuration. Set `GJC_SDK_DISABLE=1` to opt out of hosting for a top-level\n session.\n- **Notification delivery is optional.** Configure and enable a managed\n notification adapter only when remote delivery is needed; the SDK endpoint\n remains available without one.\n- **Integrations are clients.** A client discovers endpoint files, connects to\n one or more WebSockets, renders `action_needed`, and sends `reply` messages.\n- **Zero upstream change.** New transports do not require changes to\n `crates/gjc-sdk` or the JSON protocol.\n- **tmux-agnostic.** The endpoint behaves identically with or without tmux.\n\n## Endpoint discovery\n\nA running session writes a discovery file at:\n\n```\n/.gjc/state/sdk/.json\n```\n\n(`.gjc/state/` is git-ignored.) Shape:\n\n```json\n{\n \"version\": 1,\n \"sessionId\": \"019edd41-...\",\n \"pid\": 12345,\n \"host\": \"127.0.0.1\",\n \"port\": 53124,\n \"url\": \"ws://127.0.0.1:53124\",\n \"token\": \"\",\n \"startedAt\": 1718760000000,\n \"updatedAt\": 1718760000000,\n \"stale\": false\n}\n```\n\n- The file is created `0700`/`0600` (unix) and written atomically.\n- The **token is in the file** because clients need it; never log it raw.\n Stale files (dead PID, past TTL, or explicitly marked) are cleaned up on the\n next start.\n\nConnect with the token as a query parameter:\n\n```\nws://127.0.0.1:/?token=\n```\n\nA wrong/missing token is rejected at the handshake with HTTP `401`.\n\n### Internal broker launch isolation\n\nWhen the SDK starts its default internal broker or session host from the published TypeScript source, GJC uses a fixed Bun launch policy: `--no-env-file`, a product-owned empty `bunfig.toml`, absolute product entrypoint paths, and no inherited `BUN_OPTIONS` or mutable compiled-mode markers. The broker bootstraps from the product SDK directory rather than the caller project; a session host still runs with the lifecycle-authorized workspace as its process cwd.\n\nThis boundary prevents a child from newly loading caller-cwd or user-global Bun preload/dotenv policy. It cannot determine how a value already present in the parent environment was originally loaded, so ordinary provider/GJC environment values remain inherited. Default internal children, including compiled self-spawns, remove inherited `BUN_OPTIONS` so parent eval/test/inspect/debug/runtime options cannot be replayed into a detached child. Compiled binaries otherwise retain their existing self-spawn command contract, corroborated by a dedicated embedded marker and exact anchored Bun virtual-filesystem identity. The explicit `GJC_SDK_SESSION_COMMAND` session-host override remains a trusted legacy operator boundary and is not parsed as a shell-safe general command API. There is no broker-command override.\n\nBroker and per-session discovery tokens remain in their authoritative private discovery files because clients need them. Launch errors, logs, and diagnostics redact those tokens and never include the child environment or isolation configuration contents.\n\n## Protocol\n\nJSON text frames. Field names are `camelCase`; the `type` discriminator is\n`snake_case`.\n\n### Server → client\n\n`action_needed` — something needs attention:\n\n```json\n{ \"type\": \"action_needed\", \"id\": \"act_9e31\", \"kind\": \"ask\",\n \"sessionId\": \"sess-1\", \"workflowGateId\": \"wg_run_stage_1\",\n \"question\": \"Proceed?\", \"options\": [\"Yes\", \"No\"], \"recommendedIndex\": 1 }\n```\n\n```json\n{ \"type\": \"action_needed\", \"id\": \"act_a42f\", \"kind\": \"ask\",\n \"sessionId\": \"sess-1\", \"question\": \"Choose a target\", \"options\": [\"A\", \"B\"] }\n```\n\n```json\n{ \"type\": \"action_needed\", \"id\": \"idle-sess-1-7\", \"kind\": \"idle\",\n \"sessionId\": \"sess-1\", \"summary\": \"finished refactor; awaiting next step\" }\n```\n\n- `id` is an opaque, transient presentation/action ID. It is the **only** authority accepted by generic `reply.id`; use it only with the current authenticated endpoint. It is not a durable workflow ID.\n- `workflowGateId?: string` is optional, additive SDK v3 correlation metadata, present only for the active presentation of a durable workflow gate. When present, it equals that gate's Q12 `gate_id`. Its public correlation key is `(sessionId, workflowGateId)` at the current authenticated endpoint; it never authorizes generic `reply`.\n- `kind: \"ask\"` is answerable in interactive/TUI and SDK workflow-gate sessions. `kind: \"idle\"` is notify-only and ephemeral (not replayed to clients that connect later). Ordinary asks and idle frames omit `workflowGateId`.\n- `recommendedIndex?: number` is optional, zero-based display metadata for `options`. Clients must validate that it is an in-range integer and ignore malformed values. Raw option labels and reply indices remain authoritative; never decorate submitted answers or infer a recommendation from position. The additive field is wire-compatible, but Rust consumers constructing the public `ActionNeeded` struct by literal must provide `recommended_index: None` when no recommendation exists.\n- This corrects the pre-v3 documentation invariant that `action_needed.id == gate_id`: they are deliberately different values. Clients must not preserve that invariant, infer a relationship from question/options/order, or retain private route, claim, receipt, epoch, token, or endpoint-generation maps.\n\n`action_resolved` — a pending action is now terminal and **non-repliable**:\n\n```json\n{ \"type\": \"action_resolved\", \"id\": \"act_9e31\", \"resolvedBy\": \"local\" }\n```\n\n`resolvedBy` is `local` (a local/direct control retired the presentation), `client` (a remote generic reply won), or `timeout`.\n\n`reply_rejected` — sent only to the client whose reply failed:\n\n```json\n{ \"type\": \"reply_rejected\", \"id\": \"act_9e31\", \"reason\": \"already_answered\" }\n```\n\nReasons: `already_answered`, `unknown_action`, `invalid_answer`,\n`resolver_unavailable`, `idempotency_conflict`, `unauthorized`.\n\nThe frames above are the minimal contract every client implements. Threaded\nclients (like the managed Telegram daemon) may also receive optional\nserver → client frames they can render or ignore: `identity_header` (one-time\nper-session repo/branch/machine header), `context_update` (last message, task,\ngoal, token usage, model, diff), `turn_stream` (live/finalized turn output),\n`image_attachment` (agent-produced images), `activity` (busy/idle, drives the\ntyping indicator), `inbound_ack` (delivery state of an injected user message),\n`session_closed` (endpoint teardown; threaded clients may delete/archive the\nremote conversation), `config_update` (current verbosity/redact), `hello`\n(server capability/version), and `pong`. A minimal client only needs\n`action_needed`, `action_resolved`, and `reply_rejected`.\n\n### Client → server\n\n`reply` — answer a pending `ask`:\n\n```json\n{ \"type\": \"reply\", \"id\": \"act_9e31\", \"answer\": 0, \"token\": \"\" }\n```\n\n`answer` accepts:\n\n- a number — zero-based option index (`0` = first option);\n- a string — an option label, or free text;\n- an object — `{ \"selected\": [0, \"Maybe\"], \"custom\": \"...\" }` for multi-select.\n\nOptional `idempotencyKey` makes retries safe: the same key + same body re-acks;\nthe same key + different body is rejected with `idempotency_conflict`.\n\nThreaded clients may also send optional client → server frames: `user_message`\n(inject/steer a turn with free text), `config_command` (toggle verbosity/redact\nin-thread), `hello` (capability/version), and `ping`. A minimal client only\nneeds `reply`.\n\n## Model catalog query (Q10)\n\nThe SDK exposes the model catalog through the paged Q10 registry query. `Q10`,\n`models.list/current`, `models.list`, and `models.current` are exact aliases:\neach returns the same paged registry array, not a current-model singleton or a\nfiltered list. Continue using the returned cursor until `page.complete` is\ntrue.\n\nEach row preserves the five legacy fields (`provider`, `id`, `name`,\n`contextWindow`, and `maxTokens`) and additively includes `reasoning`,\n`thinking`, and `current`. `currentThinkingLevel` appears only on the current\nrow when the live session has a thinking level. The exported DTO types are\n`Q10Model`, `Q10ThinkingCapabilities`, `Q10ThinkingEffort`,\n`Q10SettableThinkingLevel`, `Q10CurrentThinkingLevel`, and\n`Q10ThinkingMode`, all from `@gajae-code/coding-agent/sdk`; there is no public\n`/sdk/models` subpath.\n\n```json\n{\n \"provider\": \"runtime-provider\",\n \"id\": \"reasoning-model\",\n \"name\": \"Reasoning Model\",\n \"contextWindow\": 128000,\n \"maxTokens\": 8192,\n \"reasoning\": true,\n \"thinking\": {\n \"validLevels\": [\"off\", \"minimal\", \"low\", \"medium\", \"high\"],\n \"minLevel\": \"minimal\",\n \"maxLevel\": \"high\",\n \"mode\": \"effort\",\n \"defaultLevel\": \"low\"\n },\n \"current\": true,\n \"currentThinkingLevel\": \"high\"\n}\n```\n\n`thinking.validLevels` is always present and starts with `\"off\"`; it is the\ncanonical menu for `model.set` and never contains `\"inherit\"`. For a\nnon-reasoning model it is exactly `[\"off\"]`. Successful reasoning rows always\ninclude `minLevel`, `maxLevel`, and `mode`; only `defaultLevel` and raw `levels`\nare optional. Raw `levels` deliberately keeps its descriptor order and\nduplicates, while `validLevels` is the canonical, deduplicated menu clients\nshould render. `\"inherit\"` is a current-state readback value only and is rejected\nas a `model.set` input.\n\nMalformed reasoning descriptors are not client-recoverable catalog data. The\nquery returns the SDK's safe `internal` error rather than exposing a partially\nformed row or descriptor details.\n\n## Prompt acceptance, termination, and reconciliation (Q26)\n\n`runtime.capabilities.promptTerminalOutcomeVersion` is `1` when this contract is available. Its normalized TypeScript terminal outcome is:\n\n```ts\ntype SdkPromptTerminalOutcome =\n\t| {\n\t\t\tkind: \"stopped\";\n\t\t\treason: \"end_turn\" | \"max_tokens\" | \"max_turn_requests\" | \"refusal\" | \"cancelled\";\n\t\t\tprovenance: \"agent\" | \"client_cancel\";\n\t }\n\t| {\n\t\t\tkind: \"failed\";\n\t\t\tcode: \"prompt_failed\" | \"prompt_deadline_exceeded\";\n\t\t\tmessage: string;\n\t\t\tprovenance: \"agent_failed\" | \"deadline\";\n\t };\n```\n\n`turn.prompt` returns `{ accepted: true, commandId, turnId, clientRef? }` only after\nits asynchronous preflight accepts the prompt. That receipt is a durable,\n**non-terminal pending claim**, not a process-durable terminal result. The SDK\nlater finalizes that claim with exactly one `SdkPromptTerminalOutcome`; cleanup\nmay follow only after the claim is durable.\n\nThe authoritative public reconciliation query is `Q26` /\n`turn.prompt_status`, scoped to the same live session runtime. Its `outcome`\nfield is exposed only after finalization. A pending claim is never represented\nor exposed as a terminal outcome.\n\nCallers that must recover from a lost acknowledgement should assign one fresh\n`clientRef` (a trimmed, non-empty string of at most 128 characters) to each logical\nprompt. Reconnect to the same session endpoint and query with exactly one selector:\n\n```json\n{ \"type\": \"query_request\", \"query\": \"turn.prompt_status\",\n \"input\": { \"clientRef\": \"request-018f\" } }\n```\n\nor:\n\n```json\n{ \"type\": \"query_request\", \"query\": \"turn.prompt_status\",\n \"input\": { \"commandId\": \"command-id\", \"turnId\": \"turn-id\" } }\n```\n\nThe result status is `accepted`, `in_flight`, `terminal_ok`, `failed`, or\n`unknown`. Known records include `acceptedAt`; in-flight and terminal records add\n`startedAt` and/or `terminalAt`; finalized records include `outcome`; failed records\nalso include a bounded sanitized `error.code` and `error.message`. Cursors, partial\ngenerated-ID pairs, mixed selectors, and extra selector fields are rejected.\n\nCorrelated `agent_end` and `agent_failed` frames carry the same finalized\n`outcome`. Clients must correlate those frames and Q26 by the prompt identifiers,\nnot infer terminality from stream activity or an earlier pending claim.\n\nReconciliation state survives client disconnect/reconnect. With the session-private\ndurable store (`.sdk-reconciliation/`), accepted and terminal prompt records also\nsurvive **GJC session-process restart** for the same session identity within\ncapacity/TTL, subject to crash-consistent fsync. A non-terminal prompt record at\nrestart finalizes its pending outcome; if that claim is absent, it finalizes as\n`{ kind: \"failed\", code: \"prompt_failed\", ... }`. This prompt-specific recovery\ndoes not apply to skill records: active `skill.invoke` records retain\n`error.code = process_restart` because their reconciliation is incomplete, not\nproof of a skill failure. Eviction or absence still returns honest `unknown`; that\nmeans the prior outcome is unknowable, not that execution did not occur. Active\nrecords are capped at 128 per kind and are never aged into terminal. Terminal\nrecords are retained for 15 minutes, capped at 256 per kind, and evicted\noldest-terminal first.\n\n`turn.prompt` remains ordered and non-idempotent. Its envelope `idempotencyKey`\ndoes not replay a response or produce `idempotency_conflict`. A retained duplicate\n`clientRef` fails before execution with `client_ref_conflict`, but callers must not\nreuse a `clientRef` as a retry mechanism: after eviction the same value can identify\na new prompt while the old outcome remains unknown.\n\n`turn.abort` returns a typed disposition. A caller that does not own the target\nreceives `resource_gone`; it must not treat that result as cancellation of another\nprompt.\n\n`sdk.promptDeadlineMs` defaults to `1_800_000`. It accepts only safe integers in\n`[60_000, 86_400_000]`; there is no disable value. The SDK snapshots the setting\nwhen the prompt is durably accepted. Terminalization then has a fixed `10_000` ms\ngrace period, which is not configurable. A controlled terminal failure reaches ACP\nas JSON-RPC `-32603` with `data.code` of `prompt_failed` or\n`prompt_deadline_exceeded`.\n\n## Skill invoke reconciliation (Q28)\n\n`skill.invoke` accepts optional `clientRef` and returns an early accepted receipt\n`{ accepted: true, commandId, turnId, clientRef?, name, path, lineCount?, args? }` after\ndurable/preflight accept (SDK control path), not after skill completion. Query prior\nstatus with `Q28` / `skill.invoke_status` using the same selectors as Q26. Kind-scoped\nindexes mean prompt and skill `clientRef` values never collide. Skill records use the\nsame capacity/TTL limits, but an active skill record at restart settles with\n`error.code = process_restart`.\n\n## Model profile discovery and validation (Q27)\n\n`Q27` / `models.profiles.list` pages the effective model-profile catalog owned by\nthe attached session. Rows are sorted by exact ID and contain only:\n\n```json\n{ \"id\": \"codex-medium\", \"displayName\": \"codex-medium\", \"source\": \"builtin\" }\n```\n\n`source` is `builtin` or `configured`. Profiles from `/models.yml`\noverride built-ins with the same exact ID, including their display label. Profile\nIDs are not trimmed, case-folded, sanitized, or restricted to safe-token names;\ndiscover the exact ID and send it unchanged. The retired `codex-standard` alias is\nfallback-only and never shadows a configured profile with that exact ID.\n\nQ27 uses retained-revision, connection-bound pagination. Continue an issued cursor\nto finish its stable snapshot; a fresh cursorless query observes the current\nregistry. The query accepts no root, path, or selector input. An invalid or\nunreadable `models.yml` fails closed with `model_profile_registry_error` rather\nthan returning a plausible built-ins-only catalog.\n\nBroker `session.create`, `session.fork`, and `session.resume` validate `modelPreset`\nbefore spawning against the same `/models.yml` authority\nthat the child receives through `GJC_AGENT_DIR` / `GJC_CODING_AGENT_DIR`. Unknown\nIDs return `unknown_model_profile`. Both typed errors include bounded `details`\nwith `requestedProfile` where applicable, whole exact `availableProfiles` entries\nthat fit the detail budget, and `discoveryQuery: \"models.profiles.list\"`. The\ndiscovery pointer is authoritative when the bounded error cannot include every ID.\n\n### Active provider query (Q29)\n\n`Q29` / `providers.list/active` pages the providers currently eligible for model\nselection through the same authenticated, retained-snapshot envelope as Q10. Each\nrow is the non-secret DTO `{ provider, connectionKind }`, where `connectionKind`\nis `credential` or `credentialless`.\n\nProvider IDs are returned exactly as they appear in Q10 `model.provider`: existing\nmixed-case, spaced, punctuated, and long custom IDs are preserved without aliases\nor normalization. Rows are deduplicated and ordered by UTF-8 provider bytes.\nJoin Q29 to Q10 by exact provider ID; Q10 remains the full configured catalog.\n\nA credentialed discovery-only provider appears only after fresh discovery proves\nthe exact model is usable. Static configured models can appear without a network\nprobe. The query never invokes a model, refreshes credentials, probes a remote\naccount, or exposes credentials, account metadata, paths, or provider responses.\n\nResolver failures are atomic and return\n`{ \"code\": \"internal\", \"message\": \"Unable to resolve active providers.\" }`.\nThey omit a page and restart metadata. An expired continuation follows the shared\ncursor contract and returns `error.code: \"cursor_expired\"` with\n`error.restartQuery: true`. Malformed cursor strings return `invalid_cursor`;\ncross-query or selector mismatches return `invalid_input`.\n\n## Answer semantics\n\nA remote reply answers a pending ask in every session state:\n\n- **Interactive / TUI mode:** the ask tool races the local selector against the\n remote reply (first valid answer wins). A client submits generic `reply` using\n the active presentation `id`; a local answer emits `action_resolved`\n (`resolvedBy: \"local\"`) and that presentation becomes non-repliable.\n- **SDK workflow gate:** generic `reply` still uses the active presentation\n `id`, never `workflowGateId`. The resolved gate drives the session the same\n way a local answer would.\n\nA session has at most one active answerable presentation. Interactive asks and durable workflow gates are serialized; further Q12 gates wait in a durable queue. A same-server reconnect replays the active `action_needed` with the same presentation ID. After a process restart, previously pending or accepted-but-unadvanced records are quarantined diagnostics and a reconstructed workflow remints fresh durable gate and presentation IDs. Terminal, stale, and reissued action IDs never regain authority.\n\nGeneric and direct controls may race. Once the native generic claim is acquired, it wins; a direct control that atomically retires the exact unclaimed active presentation first wins instead. Losing direct controls fail without advancing the gate, and losing generic replies are stale/non-repliable. Clients must not retry by matching text, durable IDs, or presentation history; they must fail closed rather than guess when session or action identity is unsafe or ambiguous.\n\n### Durable workflow controls and Q12\n\n`workflow.gate_answer` and `workflow.plan_approve` operate on the durable\nQ12 `gate_id`, not `action_needed.id`. Both accept optional\n`expectedSessionId`; clients should always send the `sessionId` observed from\nthe current authenticated endpoint:\n\n```json\n{ \"type\": \"control_request\", \"operation\": \"workflow.gate_answer\",\n \"input\": { \"id\": \"wg_run_stage_1\", \"response\": \"approve\", \"expectedSessionId\": \"sess-1\" } }\n```\n\n```json\n{ \"type\": \"control_request\", \"operation\": \"workflow.plan_approve\",\n \"input\": { \"id\": \"wg_run_stage_1\", \"choice\": \"approve\", \"expectedSessionId\": \"sess-1\" } }\n```\n\n`expectedSessionId` omission remains accepted and audited for the entire SDK v3 line so deployed v3 control clients continue to work; new clients must send it now. It cannot become mandatory, or be removed from the controls, before SDK v4 and at least one full published deprecation release/window with deployed-client notice. A supplied session mismatch is rejected before the gate resolver runs. Neither control accepts a presentation ID, remaps an old ID to a reminted gate, or uses heuristic matching.\n\nQ12 (`workflow.gates.list`) exposes durable query records and additive SDK v3 diagnostics. A pending record preserves its workflow fields including `gate_id` and adds `id: \"pending:\"` and `tag: \"pending\"`. A restart quarantine diagnostic uses `id: \"diagnostic:\"`, `tag: \"quarantined\"`, and optional `lifecycle` containing `state: \"quarantined\"`, its restart reason, `quarantinedAt`, and an optional `supersededByGateId` after a remint. Diagnostics are query-only: they cannot be routed, answered, or promoted. Treat Q12 as the durable status surface, not as generic-reply authority.\n\n### Coordinator MCP question pull loop\n\nThe Coordinator MCP bridge is a separate, public-safe pull surface for external coordinators. `gjc_coordinator_list_questions` requires `session_id` and reconciles pending `workflow.gates.list` rows on every call, returning bounded public `questions`, `diagnostics`, and `reconciliation`. It accepts `status: \"pending\"`; `status: \"open\"` remains a compatibility alias. Multiple pending rows can be returned. A pending row carries its safe question shape, public option ids, and `answer_binding`, never raw/private gate payloads or values.\n\n`gjc_coordinator_submit_question_answer` requires `session_id`, `turn_id`, `question_id`, `answer_binding`, `answer`, `idempotency_key`, and `allow_mutation: true`. It re-lists/revalidates after restart and resolves through `workflow.gate_answer`, not generic `ask.answer`. An incomplete reconciliation returns `terminal_uncertain`; stale, terminal, missing, or ownership-mismatched rows cannot be answered. Re-list after restart rather than retaining old identifiers. An identical retry with the same idempotency key replays the accepted result; conflicting reuse returns `idempotency_conflict`.\n\nThis contract does not change #2549/#2551 or unattended plain-CLI behavior.\n\n### Rust and N-API compatibility\n\nThe Rust `ActionNeeded`, `ServerMessage`, and `register_ask` APIs remain\nlegacy-compatible and uncorrelated. Correlation is available through additive\nRust workflow-frame decoding/current-reader APIs and the workflow registration\npath; consumers that need correlation must opt in explicitly. N-API likewise\nretains `registerAsk`, and adds `registerWorkflowGateAsk` for a correlated wire\nframe plus `registerArbitratedAsk` and `retireIfUnclaimed` for in-process\npresentation arbitration. The arbitration lease and all claim/receipt/epoch\nstate remain private: these APIs do not create a public authority value.\n\n### Runtime and native addon release pairing\n\nThe `@gajae-code/coding-agent` runtime and `@gajae-code/natives` native addon ship from the same source release at exact matching package versions. The native loader requires the matching version sentinel; mixed native/runtime versions are unsupported and must not claim SDK compatibility.\n\n## Minimal client example\n\n```js\nimport { readFileSync } from \"node:fs\";\nimport WebSocket from \"ws\";\n\nconst { url, token } = JSON.parse(\n readFileSync(`.gjc/state/sdk/${sessionId}.json`, \"utf8\"),\n);\n\nconst ws = new WebSocket(`${url}/?token=${encodeURIComponent(token)}`);\n\nws.on(\"message\", (data) => {\n const msg = JSON.parse(data.toString());\n if (msg.type === \"action_needed\" && msg.kind === \"ask\") {\n // present msg.question / msg.options to the human, then:\n ws.send(JSON.stringify({ type: \"reply\", id: msg.id, answer: 0, token }));\n } else if (msg.type === \"action_resolved\") {\n // mark this action as no longer answerable in your UI\n } else if (msg.type === \"reply_rejected\") {\n // e.g. reason === \"already_answered\" → the ask was answered elsewhere\n }\n});\n```\n\nSwap `ws` for a Telegram bot's long-poll loop, a Discord gateway client, or a\nSlack socket-mode app — the contract above is all you implement.\n\n## Fallback chains\n\nModel-role selectors may be ordered fallback chains; see [Fallback chains](./models.md#fallback-chains) for configuration and retry-budget details. Resolution-time skips do not consume attempts. When a request-time retry advances to another eligible entry, the selected default fallback remains sticky for later prompts in that session until an explicit model selection or a chain reset changes it.\n\n`model_fallback_switched { eventId, from, to, reason, role, scope, activeIndex, chainLength, attemptsUsed }` is the canonical session lifecycle event for every real fallback-model switch. It replaces the legacy `retry_fallback_applied` / `retry_fallback_succeeded` event names. Embedding clients can subscribe to this session event; generic WebSocket clients should use only the protocol frames documented above and any adapter-specific status updates they support.\n\n\n## Managed session-directory adapter guidance\n\nSDK adapters that need to inspect saved sessions must import only the supported public surface from `@gajae-code/coding-agent/sdk`:\n\n```ts\nimport {\n SESSION_DIRECTORY_API_VERSION,\n listManagedSessionCandidates,\n resolveManagedSessionScope,\n} from \"@gajae-code/coding-agent/sdk\";\n\nif (SESSION_DIRECTORY_API_VERSION !== 1) throw new Error(\"Unsupported session-directory API\");\nconst resolved = await resolveManagedSessionScope({ cwd: process.cwd() });\nif (resolved.kind === \"resolved\") {\n const listing = await listManagedSessionCandidates({ scope: resolved.scope });\n // Consume only listing.kind === \"complete\" and its owned candidates.\n}\n```\n\nThis is a readonly resolver/listing contract. Do not import `@gajae-code/coding-agent/session/internal/*`, derive `v2-…` names, write bindings, or implement migration/cleanup in an adapter; private internal subpaths are intentionally unavailable from the packaged module. Treat `network_unsupported`, binding/security errors, incomplete listings, invalid candidates, and foreign candidates as non-authoritative results rather than retrying with a guessed path.\n\nThe resolver uses canonical native identity: supported POSIX and Windows local aliases can designate one scope, while UNC/network workspaces are unsupported. Scope digests are collision-resistant identifiers, not injective aliases, credentials, or authentication. The owner-only checks protect managed local storage paths but do not authenticate an adapter or make hostile concurrent filesystem races safe. Adapters that need mutations must use the higher-level lifecycle/session APIs rather than the readonly directory API.\n## Managed notification adapters\n\nGJC ships managed SDK-client adapters for Telegram, Discord, and Slack. They use\none local SDK endpoint per session; the adapters do not change the wire protocol,\nkeep endpoint credentials in provider state, or expose a remote shell.\n\nThe recommended interactive path is `/settings` → **Notifications**. It owns\nsetup, health, test, recovery, reconnect, local enablement, and Telegram\nremoval without exposing stored credentials.\n`gjc notify setup` remains the authoritative CLI fallback for headless and\nautomated environments.\n\nNotification credentials and `notifications.*` settings are global-only.\nProject notification keys are\nignored and runtime notification overrides are rejected. Telegram pairing\nrevalidates the complete bot-token/chat identity immediately before polling and\nagain before activation. A foreign or unknown owner is never killed, reloaded, or taken over;\nsetup fails closed without saving or exposing the raw token.\n\nConfiguration completeness, provider-local quarantine, durable desired intent, effective enablement, runtime readiness, and delivery outcomes are separate contracts. The global `notifications.enabled` master never erases provider credentials or desired flags. `/settings` edits secrets through explicit `keep`, `replace`, or `remove` actions, commits only the selected provider in one CAS batch, and reports post-commit observer or activation failures without pretending the durable save rolled back. Malformed provider-local values are quarantined for explicit repair while safe sibling providers remain usable; malformed global notification structure remains fail-closed.\n\n`GJC_NOTIFICATIONS=0` suppresses only automatic generic current-session admission. Explicit `/notify on` can opt the current session back in without mutating durable provider state, and direct provider APIs remain governed by provider effectiveness and their own runtime readiness. If Telegram ownership is proven foreign while Discord or Slack is effective, GJC publishes the chat daemon endpoint under the isolated `.gjc/state/chat/sdk/` discovery path; the blocked Telegram scanner never receives the shared endpoint token.\n\n- [Telegram notification onboarding](./telegram-onboarding.md) documents\n `gjc notify setup` and private-chat pairing.\n- [Discord notification onboarding](./discord-onboarding.md) documents\n `gjc notify setup discord`, required configuration, thread lifecycle, and\n least-privilege permissions.\n- [Slack notification onboarding](./slack-onboarding.md) documents\n `gjc notify setup slack`, Socket Mode configuration, immediate envelope ack,\n and thread lifecycle.\n\n`gjc notify status` reports provider completeness, repair/quarantine state, desired intent, effective enablement, and masked tokens. Destination identifiers remain visible and may be sensitive. The Discord and Slack setup commands are non-interactive and require their documented identifier and token flags; supply secrets through an approved local mechanism, not examples, committed files, shell history, logs, or chat. `gjc notify health --provider --probe` performs a provider-owned REST diagnostic even when complete credentials are intentionally inactive, while `gjc notify test --provider ` additionally requires effective enablement and runtime readiness.\n\nThe daemon/session engine is shared. Session discovery, WebSocket protocol,\nredaction decisions, rate-limit pooling, reply routing, singleton ownership, and\nlifecycle control are not reimplemented by each chat surface. Telegram, Discord,\nand Slack adapters are thin presentation layers: they render internal notification\nevents into transport payloads and map transport interactions back to `{sessionId,\nactionId,answer}` replies.\n\nDiscord maps a session to an archiveable thread; resume unarchives it or creates\na replacement, and stale/superseded thread input fails closed. Slack maps a\nsession to an immutable root thread; resume creates a new root, acknowledges all\nSocket Mode envelopes immediately, and does not persist a Socket Mode cursor.\n\nThe Discord and Slack acceptance suites use fake providers only. They exercise\nprovider failure, reconciliation, restart, dedupe, lifecycle, and reconnect paths\nwithout live credentials or live-provider end-to-end tests.\n\n## Managed Telegram daemon (bundled reference client)\n\nGJC also ships a managed Telegram reference client for the common phone-notify\nworkflow. It remains a client of the generic SDK: it scans session discovery\nfiles, opens each session WebSocket, and routes Telegram replies back to the\nmatching endpoint. Run `gjc notify setup` once to complete Telegram's interactive\nprivate-chat pairing flow.\n\nFor Telegram forum topics, the daemon deletes the per-session topic when the local\nnotification endpoint shuts down, so it disappears from the topic list. A resumed\nsession creates a fresh topic before sending again. The bot must be allowed to\ndelete messages in that chat; without that permission, deletion is best-effort and\ndelivery continues.\n\n### Singleton poller and trust model\n\nTelegram `getUpdates` allows only one active long-poll owner per bot token. The\nmanaged daemon enforces **one bot token = one getUpdates poller** with a local\nlock/state file under the agent directory. New sessions attach to the existing\nfresh daemon owner instead of starting another poller, preventing Telegram 409\nconflicts.\n\nThe trust model is intentionally strict:\n\n- setup pairs exactly one private Telegram chat;\n- runtime accepts updates only from that paired chat id;\n- groups, supergroups, channels, and unpaired users never receive session names,\n action ids, pending status, or configuration hints;\n- daemon state stores a token fingerprint, not the raw bot token.\n\n### Routing in private-chat topics\n\nThe paired private chat prefers per-session Telegram topics (Threaded Mode). The\ndaemon tags messages by session, stores compact callback aliases for inline\nbuttons, and routes replies back to the exact session/action. A forum-enabled\nsupergroup is no longer required: when the bot owner enables Threaded Mode in\n@BotFather, the daemon creates one topic per session in the paired private chat.\nGJC cannot enable Threaded Mode through the Bot API; setup only verifies the\ncapability and guides the manual BotFather toggle.\n\nIf BotFather's per-bot **Bot Settings** menu does not show **Threads Settings**\nor **Threaded Mode**, the supported fallback is the normal private-chat pairing.\nSetup can be saved as `threaded=unverified`/`threaded=unknown`, and the daemon\nstill tries topics when Telegram allows them. When `createForumTopic` is refused,\nthe daemon does not drop the send: it routes the notification to the normal\n(flat) paired private chat and posts a one-time nudge: `Flat Telegram private chat\nsupports outbound notifications and inline ask buttons only. Enable Threaded Mode\nin @BotFather > Bot Settings > Threads Settings for free-text replies and session\ncommands.` Pairing is private-only, so flat delivery stays within the user's own\nprivate DM.\n\nSupported reply paths:\n\n- tap an inline button on an ask notification;\n- reply inside the session's thread/topic (replies are thread-native; the\n topic identifies the session, so no session tag is needed).\n\nIn threaded mode the user can also adjust per-session behaviour with in-thread\nconfig commands: `/verbose` (per-tool-turn assistant text), `/lean` (settled\nassistant answer at idle plus immediate ask lead-ins; the default),\n`/verbosity `, and `/redact `. The legacy\n`/answer ` command is removed — replies are routed by the\ntopic they arrive in.\n\nFlat fallback keeps outbound notifications and inline-button answers working, but\nplain free-text never guesses from the global pending-ask set. Free-text replies\nand `/verbose`/`/lean`/`/verbosity`/`/redact` commands are thread-native and\nrequire Threaded Mode/topic routing. Enable Threaded Mode in @BotFather > Bot\nSettings > Threads Settings when you need free-text replies or session commands.\nDo not pair a group, supergroup, or channel to work around a missing BotFather\nmenu; the bundled setup flow is\nprivate-chat only, and non-private chat ids remain fail-closed to avoid session\ndata leaks.\n\nUnknown, expired, or restart-unvalidated callback aliases fail closed: the daemon\nsends guidance and does not guess a target session or action.\n\n### Discord and Slack setup\n\nDiscord and Slack use the same internal notification events and reply protocol as\nTelegram. Store only runtime credentials in local GJC settings or environment;\nnever paste bot tokens, webhook URLs, transcripts, prompts, host paths, or raw logs\ninto docs, tests, issues, or PR comments.\n\nConfiguration keys:\n\n```yaml\nnotifications:\n enabled: true\n discord:\n botToken: \"\"\n applicationId: \"\"\n guildId: \"\"\n parentChannelId: \"\"\n slack:\n botToken: \"\"\n appToken: \"\"\n workspaceId: \"\"\n channelId: \"\"\n authorizedUserId: \"\"\n redact: true\n```\n\nThe bundled adapters intentionally render public-safe message bodies and return\nroute metadata only for pending internal actions. They do not own polling,\nsession scans, daemon locks, rate limits, or SDK lifecycle. Production transport\nsenders should consume the adapter payloads and keep all credential-bearing HTTP\nor gateway details outside logged payloads.\n### Redaction\n\n`notifications.redact` strips sensitive content before remote delivery, but\n**asks are exempt**: an ask is an interactive prompt the human must read and\nanswer remotely, so its `question` and `options` are always sent unredacted\n(otherwise it would be unanswerable). When redaction is enabled, `idle`\nsummaries are removed and streamed content frames (`turn_stream`,\n`context_update`, `image_attachment`) are suppressed at their emit sites. When\nredaction is disabled, all content is delivered unchanged.\n\n### Local `/notify`\n\nInside a GJC session, `/notify` controls the current session only:\n\n- `/notify status` reports enabled/disabled state, daemon observation when known,\n and redaction state without printing secrets;\n- `/notify off` disables the current session's notification endpoint and removes\n its discovery record without mutating global Settings;\n- `/notify on` re-enables the current session when global setup is complete and\n `GJC_NOTIFICATIONS=0` is not forcing opt-out.\n\n### Manual Telegram CLI is for debugging\n\n`packages/coding-agent/src/sdk/bus/telegram-cli.ts` remains as a manual\nreference/debug client and template for other integrations. It is not the primary\nTelegram UX.\n\n```sh\nbun run packages/coding-agent/src/sdk/bus/telegram-cli.ts --bot-token \"$BOT_TOKEN\"\n```\n\nBy default it refuses to start when a fresh managed daemon already owns the same\nbot token for the same paired chat, because a second poller will cause Telegram\n409 conflicts. Use `--force` only for deliberate debugging when you have stopped\nor intentionally want to override the daemon guard.\n## Two client surfaces: per-session vs daemon-owned lifecycle control\n\nThe SDK now exposes **two distinct surfaces**. Do not confuse them:\n\n1. **Per-session notification clients (the normal, documented contract above).**\n A client discovers `/.gjc/state/sdk/.json`, connects\n to that session's loopback WebSocket, and handles `action_needed`,\n `action_resolved`, `reply_rejected`, and the optional threaded frames. This is\n all an ordinary integration (Telegram, Discord, Slack, mobile, local tools)\n needs. It requires **zero** upstream changes.\n\n2. **The daemon-owned session *lifecycle* control endpoint (privileged).**\n A separate, **session-independent**, loopback-only, authenticated control\n endpoint that accepts `session_create` / `session_close` / `session_resume`\n frames. It exists because creating a session cannot use a per-session socket\n (none exists before the session does). It is **not** part of the normal\n integration contract: ordinary clients never implement it. Only the bundled,\n trusted daemon (e.g. the managed Telegram daemon) speaks it.\n\n### Lifecycle control endpoint\n\n- **Discovery:** `/notifications/control.json` (daemon-owned, mode\n `0600`), distinct from per-session endpoint files. It carries only non-secret\n endpoint metadata (url/host/port/pid/owner). The control token is held **in\n memory** by the daemon (the sole client) and is **never** written to disk.\n- **Auth and routing:** the loopback SDK broker requires\n `?token=` (HTTP `401` otherwise) and re-checks every\n lifecycle frame's `token` (`unauthorized` on mismatch). It routes accepted\n requests through the canonical SDK lifecycle operation.\n- **Frames:** `session_create` (target `existing_path` | `worktree` |\n `plain_dir`), `session_close` (hard-kill, history preserved, recoverable),\n `session_resume` (reattach if alive, else cold-restart from history); responses\n `session_create_response` / `session_close_response` / `session_resume_response`\n / `session_lifecycle_error`. The protocol also defines a replayable\n `session_ready` per-session frame for readiness-gated creates; the current MVP\n daemon replies once the tmux launch is requested (see the phone guide) rather\n than waiting on it. Inline prompt text (`-- `) is rejected in the MVP.\n\n### Trust model and hardening (daemon side)\n\nThe control endpoint trusts the configured paired chat for any path (an accepted\nrisk). It is hardened around that boundary:\n\n- **Strict paired-chat gating** — non-paired chats are rejected *before* any path\n parsing, filesystem, or process action.\n- **Durable idempotency** — a locked, atomic, fsynced ledger keyed by\n `chatId:updateId` + request hash (`telegram-lifecycle-idempotency.json`).\n Duplicate updates never repeat side effects, including across daemon restart; a\n duplicate while in-progress reports pending (never a second spawn); a same id\n with a different body is `duplicate_conflict`; an effect failure is recorded\n `terminal_uncertain` (never auto-respawned).\n- **Per-chat create rate limit.**\n- **Audit log** — append-only `telegram-lifecycle-audit.jsonl` (`0600`) recording\n every accept/reject/duplicate/rate-limit/spawn/success/failure. Raw control\n tokens and raw prompts are never logged (prompt hash + byte length only).\n- **Inline prompts rejected (MVP)** — `session_create` with `-- ` text is\n rejected with usage; no prompt is ever placed in argv, audit, or responses. (A\n redacted prompt-ref flow is reserved for a future revision.)\n- **GJC-managed-only close** — force-close re-reads the exact `@gjc-profile`\n immediately before kill and requires the `@gjc-session-id` (and optional\n `@gjc-session-state-file`) tag to match; it never touches non-GJC tmux.\n- **Recent-activity picker** — sessions are ranked by history-file mtime and\n enriched with terminal breadcrumbs so the operator picks a recent repo/session\n instead of typing raw paths. Ambiguous resumes fail closed with candidates.\n### Phone test guide (create / close / resume from Telegram)\n\nEnd-to-end manual check once `gjc notify setup` has paired your private chat:\n\n1. **Pair + start.** Run `gjc notify setup` (BotFather token, DM the bot to pair).\n Start any GJC session with notifications enabled so the daemon owner is\n running (`gjc launch` in a repo, or `GJC_NOTIFICATIONS=1`). The owner starts\n the loopback control endpoint and accepts `/session_*` while running; with zero\n active sessions it still idle-exits after the inactivity timeout.\n2. **Create.** From your paired chat, pick `/session_create` from the Telegram\n command menu or send `/session_create path ` (or\n `/session_create worktree `, or `/session_create dir `).\n ``, ``, and `` may use `~`/`~/...` for your own home\n directory; named-user forms such as `~alice/repo` are rejected. The bot replies\n once the tmux launch is requested; the session shows up in `/session_recent`\n once it is ready. (Inline prompts via `-- ` are rejected for now with\n usage text.)\n3. **List.** `/session_recent` shows recent sessions (most-recent first) to copy\n an id from.\n4. **Close.** `/session_close ` hard-kills the GJC-managed session\n (history is preserved); the bot confirms.\n5. **Resume.** `/session_resume ` reattaches if it is still\n alive, otherwise cold-restarts it from saved history. An ambiguous prefix\n replies with the matching candidates instead of guessing.\n\nCommands are accepted **only** from the paired chat; **create** is rate-limited,\nand all lifecycle commands are idempotent per Telegram update id and audited (no\ntokens or prompts are logged).\nFor an automated proof of the wire path without a real bot, see\n`packages/coding-agent/scripts/g011-daemon-path-smoke.ts` (real native control\nendpoint + loopback WebSocket).\n", "secrets.md": "# Secret Obfuscation\n\nPrevents sensitive values (API keys, tokens, passwords) from being sent to LLM providers. When enabled, secrets are replaced with authenticated placeholders before leaving the process, and restored in tool call arguments returned by the model.\n\n## Enabling\n\nDisabled by default. Toggle via `/settings` UI or directly in `config.yml`:\n\n```yaml\nsecrets:\n enabled: true\n```\n\n## How it works\n\n1. On session startup, secrets are collected from two sources:\n - **Environment variables** whose names match common secret patterns (`KEY`, `SECRET`, `TOKEN`, `PASSWORD`, `PASS`, `AUTH`, `CREDENTIAL`, `PRIVATE`, `OAUTH`) with values >= 8 characters\n - **`secrets.yml` files** (see below)\n\n2. Outbound text messages to the LLM have secret values replaced with authenticated, versioned placeholders like `#GJC1_…#`.\n\n3. Session context/tool arguments returned from the model are deep-walked and obfuscation placeholders are restored to original values before display or execution.\n\nTwo modes control what happens to each secret:\n\n| Mode | Behavior | Reversible |\n| --------------------- | ----------------------------------------------- | ----------------------------------------------- |\n| `obfuscate` (default) | Replaced with authenticated `#GJC1_…#` token | Yes (deobfuscated in tool args/session context) |\n| `replace` | Replaced with deterministic same-length string | No (one-way) |\n\nAuthenticated placeholders use a process-local key. Plain-secret tokens remain stable across sessions, reloads, and forks within the running process; after a process restart, earlier tokens intentionally remain opaque.\n\nRegex-discovered tokens are reversible only by the originating obfuscator instance. A fresh obfuscator in the same process or after restart keeps them opaque because regex matches are not reconstructed from persisted placeholders.\n\n## secrets.yml\n\nDefine custom secret entries in YAML. Two locations are checked:\n\n| Level | Path | Purpose |\n| ------- | -------------------------- | --------------------------- |\n| Global | `~/.gjc/agent/secrets.yml` | Plain and regex secrets across all projects |\n| Project | `/.gjc/secrets.yml` | Project-specific plain secrets |\n\nProject plain entries override global plain entries with matching `content`; a global regex with the same `content` remains active. Project-scope regex entries are ignored because workspace-contained files are not trusted to supply executable regex patterns. This project scope includes `/.gjc/secrets.yml` and any caller-supplied agent directory whose lexical or canonical path is contained within the workspace.\n\n### Schema\n\nEach entry in the array has these fields:\n\n| Field | Type | Required | Description |\n| ------------- | ---------------------------- | -------- | ------------------------------------------------- |\n| `type` | `\"plain\"` or `\"regex\"` | Yes | Match strategy |\n| `content` | string | Yes | The secret value (plain) or regex pattern (regex) |\n| `mode` | `\"obfuscate\"` or `\"replace\"` | No | Default: `\"obfuscate\"` |\n| `replacement` | string | No | Custom replacement (replace mode only) |\n| `flags` | string | No | Regex flags (regex type only) |\n\n### Examples\n\n#### Plain secrets\n\n```yaml\n# Obfuscate a specific API key (default mode)\n- type: plain\n content: sk-proj-abc123def456\n\n# Replace a database password with a fixed string\n- type: plain\n content: hunter2\n mode: replace\n replacement: \"********\"\n```\n\n#### Regex secrets\n\nRegex entries are supported only by agent configuration outside the current workspace (normally `~/.gjc/agent/secrets.yml`). Use `type: plain` for workspace-contained configuration.\n\n```yaml\n# Obfuscate any AWS-style key\n- type: regex\n content: \"AKIA[0-9A-Z]{16}\"\n\n# Case-insensitive match with explicit flags\n- type: regex\n content: \"api[_-]?key\\\\s*=\\\\s*\\\\w+\"\n flags: \"i\"\n\n# Regex literal syntax (pattern and flags in one string)\n- type: regex\n content: \"/bearer\\\\s+[a-zA-Z0-9._~+\\\\/=-]+/i\"\n```\n\nRegex entries always scan globally (the `g` flag is enforced automatically). The regex literal syntax `/pattern/flags` is supported as an alternative to separate `content` + `flags` fields. Escaped slashes within the pattern (`\\\\/`) are handled correctly. The sticky `y` flag is rejected because it would prevent global scanning.\n\n#### Replace mode with regex\n\n```yaml\n# One-way replace connection strings (not reversible)\n- type: regex\n content: \"postgres://[^\\\\s]+\"\n mode: replace\n replacement: \"postgres://***\"\n```\n\n## Interaction with env var detection\n\nEnvironment variables are collected first, then file-defined entries are appended. File entries can cover secrets that don't live in env vars (config files, hardcoded values, etc.). If the same plain value appears in both env and file entries, the env entry's obfuscate-mode mapping is used first.\n\n## Key files\n\n- `packages/coding-agent/src/secrets/index.ts` -- loading, merging, env var collection\n- `packages/coding-agent/src/secrets/obfuscator.ts` -- `SecretObfuscator` class, placeholder generation, message obfuscation\n- `packages/coding-agent/src/secrets/regex.ts` -- regex literal parsing and compilation\n- `packages/coding-agent/src/config/settings-schema.ts` -- `secrets.enabled` setting definition\n\n## See also\n\n- [`auth-broker-gateway.md`](./auth-broker-gateway.md) -- remote credential vault and forward-proxy that keep provider OAuth refresh tokens and access tokens off developer hosts entirely (complementary to in-process obfuscation).\n", "session-operations-export-share-fork-resume.md": "# Session Operations: export, dump, share, fork, resume/continue\n\nThis document describes operator-visible behavior for session export/share/fork/resume operations as currently implemented.\n\n## Implementation files\n\n- [`../src/modes/controllers/command-controller.ts`](../packages/coding-agent/src/modes/controllers/command-controller.ts)\n- [`../src/session/agent-session.ts`](../packages/coding-agent/src/session/agent-session.ts)\n- [`../src/session/session-manager.ts`](../packages/coding-agent/src/session/session-manager.ts)\n- [`../src/session-import/`](../packages/coding-agent/src/session-import/)\n- [`../src/export/html/index.ts`](../packages/coding-agent/src/export/html/index.ts)\n- [`../src/export/custom-share.ts`](../packages/coding-agent/src/export/custom-share.ts)\n- [`../src/main.ts`](../packages/coding-agent/src/main.ts)\n\n## Operation matrix\n\n| Operation | Entry path | Session mutation | Session file creation/switch | Output artifact |\n| --------------------------------------- | ------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ---- |\n| `/dump` | Interactive slash command | No | No | Clipboard text |\n| `/export [path]` | Interactive slash command | No | No | HTML file |\n| `--export [outputPath]` | CLI startup fast-path | No runtime session mutation | No active session; reads target file | HTML file |\n| `/share` | Interactive slash command | No | No | Temp HTML + share URL/gist |\n| `/fork` | Interactive slash command | Yes (active session identity changes) | Creates new session file and switches current session to it (persistent mode only) | Copies artifact directory to new session namespace when present |\n| `--fork ` | CLI startup | Yes after session creation | Creates a new session fork from the selected source into current cwd/session dir | None |\n| `/import-session codex [session-id ...]` | Interactive or trusted local startup command | No active-session mutation | Creates independently resumable native v5 session files | Bounded quarantine and provenance manifest |\n| `/resume` | Interactive slash command | Yes (active in-memory state replaced) | Switches to selected existing session file | None |\n| `--resume` | CLI startup (picker) | Yes after session creation | Opens selected existing session file | None |\n| `--resume ` | CLI startup | Yes after session creation | Opens existing session; cross-project case can fork into current project | None |\n| `--continue` | CLI startup | Yes after session creation | Opens terminal breadcrumb or most-recent session; creates new one if none exists | None |\n\n## Import Codex sessions\n\n`/import-session codex ...` imports the named Codex CLI sessions that belong to the current workspace. With no IDs, it imports every discovered Codex session whose recorded canonical working directory exactly matches the current workspace.\n\nThe importer converts user, assistant, function/custom-tool call, and tool-result records into native v5 history. Unsupported records are sanitized and written to a bounded `codex-quarantine.jsonl` attachment; the source provider/session ID, source digest, byte counts, converter versions, mapping counts, and quarantine binding are retained in the transcript provenance and managed manifest. Raw Codex archives are never copied into the session store.\n\nEach source is independently staged, validated, fsynced, and published without replacement. Repeating the command verifies provenance and returns the existing imported session, including after that session has been resumed and continued. Imported sessions do not replace the active session automatically; select them with `/resume`.\n\nThe command is available only in the interactive TUI and trusted local startup command path. It is neither advertised nor dispatched over ACP or remote-control transports. Source files must be regular, single-link, non-symlink files beneath the selected Codex sessions directory. Secret-bearing fields, credential-shaped values, terminal escape sequences, bidi/zero-width controls, and provider control tokens are redacted before persistence.\nTrusted source import currently requires Linux descriptor-relative filesystem authority and fails closed on platforms where that authority is unavailable.\n## Export and dump\n\n### `/export [outputPath]` (interactive)\n\nFlow:\n\n1. `InputController` routes `/export...` to `CommandController.handleExportCommand`.\n2. The command splits on whitespace and uses only the first argument after `/export` as `outputPath`.\n3. `AgentSession.exportToHtml()` calls `exportSessionToHtml(sessionManager, state, { outputPath, themeName })`.\n4. On success, UI shows path and opens the file in browser.\n\nBehavior details:\n\n- `--copy`, `clipboard`, and `copy` arguments are explicitly rejected with a warning to use `/dump`.\n- Export embeds session header/entries/leaf plus current `systemPrompt` and tool descriptions from agent state.\n- No session entries are appended during export.\n\nCaveat:\n\n- Argument parsing is whitespace-based (`text.split(/\\s+/)`), so quoted paths with spaces are not preserved as a single path by this command path.\n\n### `--export [outputPath]` (CLI)\n\nFlow in `main.ts`:\n\n1. Handled early (before interactive/session startup).\n2. Calls `exportFromFile(inputPath, outputPath?)`.\n3. `SessionManager.open(inputPath)` loads entries, then HTML is generated and written.\n4. Process prints `Exported to: ...` and exits.\n\nBehavior details:\n\n- Missing input file surfaces as `File not found: `.\n- This path does not create an `AgentSession` and does not mutate any running session.\n\n### `/dump` (interactive clipboard export)\n\nFlow:\n\n1. `CommandController.handleDumpCommand()` calls `session.formatSessionAsText()`.\n2. If empty string, reports `No messages to dump yet.`\n3. Otherwise copies to clipboard via native `copyToClipboard`.\n\nDump content includes:\n\n- System prompt\n- Active model/thinking level\n- Tool definitions + parameters\n- User/assistant messages\n- Thinking blocks and tool calls\n- Tool results and execution blocks (except `excludeFromContext` bash/python entries)\n- Custom/hook/file mention/branch summary/compaction summary entries\n\nNo session persistence changes are made by dumping.\n\n## Share\n\n`/share` is interactive-only and always starts by exporting current session to a temp HTML file.\n\n### Phase 1: temp export\n\n- Temp file path: `${os.tmpdir()}/${Snowflake.next()}.html`\n- Uses `session.exportToHtml(tmpFile)`\n- If export fails (notably in-memory sessions), share ends with error.\n\n### Phase 2: custom share handler (if present)\n\n`loadCustomShare()` checks `~/.gjc/agent` for first existing candidate:\n\n- `share.ts`\n- `share.js`\n- `share.mjs`\n\nRequirements:\n\n- Module must default-export a function `(htmlPath) => Promise`.\n\nIf present and valid:\n\n- UI enters `Sharing...` loader state.\n- Handler result interpretation:\n - string => treated as URL, shown and opened\n - object => `url` and/or `message` shown; `url` opened\n - `undefined`/falsy => generic `Session shared`\n- Temp file is removed after completion.\n\nCritical fallback behavior:\n\n- If custom handler exists but loading fails, command errors and returns.\n- If custom handler executes and throws, command errors and returns.\n- In both failure cases, it **does not** fall back to GitHub gist.\n- Gist fallback happens only when no custom share script exists.\n\n### Phase 3: default gist fallback\n\nOnly when no custom share handler is found:\n\n1. Validates `gh auth status`.\n2. Shows `Creating gist...` loader.\n3. Runs `gh gist create --public=false `.\n4. Parses gist URL, derives gist id, builds preview URL `https://gistpreview.github.io/?`.\n5. Shows both preview and gist URLs; opens preview.\n\nCancellation/abort semantics in share:\n\n- Loader has `onAbort` hook that restores editor UI and reports `Share cancelled`.\n- The underlying `gh gist create` command is not passed an abort signal in this code path; cancellation is UI-level and checked after command returns.\n\n## Fork\n\nInteractive `/fork` creates a new session from the current one and switches the active session identity.\n\n### Preconditions and immediate guards\n\n- If agent is streaming, `/fork` is rejected with warning.\n- UI status/loading indicators are cleared before operation.\n\n### Session-level flow\n\n`AgentSession.fork()`:\n\n1. Emits `session_before_switch` with `reason: \"fork\"` (cancellable).\n2. Flushes pending writes.\n3. Calls `SessionManager.fork()`.\n4. Copies artifacts directory from old session namespace to new namespace (best-effort; non-ENOENT copy failures are logged, not fatal).\n5. Updates `agent.sessionId`.\n6. Emits `session_switch` with `reason: \"fork\"`.\n\n`SessionManager.fork()` behavior:\n\n- Requires persistent mode and existing session file.\n- Creates new session id and new JSONL file path.\n- Rewrites header with:\n - new `id`\n - new timestamp\n - `cwd` unchanged\n - `parentSession` set to previous session id\n- Keeps all non-header entries unchanged in the new file.\n\n### Non-persistent behavior\n\n- In-memory session manager returns `undefined` from `fork()`.\n- `AgentSession.fork()` returns `false`.\n- UI reports `Fork failed (session not persisted or cancelled)`.\n\n### CLI `--fork `\n\nStartup `--fork` is resolved before normal session creation:\n\n1. `--fork` is rejected with `--no-session`.\n2. Path-like values (`/`, `\\`, or `.jsonl`) call `SessionManager.forkFrom(path, cwd, sessionDir)`.\n3. Other values resolve like resumable session ids via current scope and then global search when allowed.\n4. The forked file is created in the current cwd/session-dir scope and becomes the active session manager for startup.\n\n### Managed directory migration during session operations\n\nDefault persistent creates and forks write only to the managed v2 workspace scope. A resume/list operation may surface a validated legacy candidate for the same canonical workspace identity; with `session.directoryMigration: \"copy-retain\"`, the migration path copies it into v2 and retains the source. It never replaces an existing destination, and a migration tombstone prevents completed/retired legacy work from being retried as fresh work. `disabled` leaves legacy data in place.\n\nThe migration path does not delete legacy sessions or artifacts automatically. It fails closed on conflicting bindings, changed source identity, unsafe artifact trees, or unavailable owner-only path security; it does not claim authentication or protection against hostile concurrent filesystem races. Explicit `--session-dir` remains an operator-selected override.\n\n## Resume and continue\n\n## Interactive `/resume`\n\nFlow:\n\n1. Opens session selector populated via `SessionManager.list(currentCwd, currentSessionDir)`.\n2. On selection, `SelectorController.handleResumeSession(sessionPath)` calls `session.switchSession(sessionPath)`.\n3. UI clears/rebuilds chat and todos, then reports `Resumed session`.\n\nNotes:\n\n- This picker only lists sessions in the current session directory scope.\n- It does not use global cross-project search.\n\n## CLI `--resume`\n\n### `--resume` (no value)\n\n- `main.ts` lists sessions for current cwd/sessionDir and opens picker.\n- Selected path is opened with `SessionManager.open(selectedPath)` before session creation.\n\n### `--resume `\n\n`createSessionManager()` resolution order:\n\n1. If value looks like path (`/`, `\\`, or `.jsonl`), open directly.\n2. Else treat as id prefix:\n - search current scope (`SessionManager.list(cwd, sessionDir)`)\n - if not found and no explicit `sessionDir`, search global (`SessionManager.listAll()`)\n\nCross-project id match behavior:\n\n- If matched session cwd differs from current cwd, CLI asks:\n - `Session found in different project ... Fork into current directory? [y/N]`\n- On yes: `SessionManager.forkFrom(match.path, cwd, sessionDir)` creates a new local forked file.\n- On no/non-TTY default: command errors.\n\n## CLI `--continue`\n\n`SessionManager.continueRecent(cwd, sessionDir)`:\n\n1. Resolves session dir for current cwd.\n2. Reads terminal-scoped breadcrumb first.\n3. Falls back to most recently modified session file.\n4. Opens found session; if none exists, creates new session.\n\nThis is startup-only behavior; there is no interactive `/continue` slash command.\n\n## How session switching actually mutates runtime state\n\n`AgentSession.switchSession(sessionPath)` does the runtime transition used by resume-like operations:\n\n1. Emit `session_before_switch` with `reason: \"resume\"` and `targetSessionFile` (cancellable).\n2. Disconnect agent event subscription and abort in-flight work.\n3. Clear queued steering/follow-up/next-turn messages.\n4. Flush current session manager writes.\n5. `sessionManager.setSessionFile(sessionPath)` and update `agent.sessionId`.\n6. Build session context from loaded entries.\n7. Emit `session_switch` with `reason: \"resume\"`.\n8. Replace agent messages from context.\n9. Restore model (if available in current registry).\n10. Restore or initialize thinking level.\n11. Reconnect agent event subscription.\n\nNo new session file is created by `switchSession()` itself.\n\n## Event emissions and cancellation points\n\n### Switch/fork lifecycle hooks\n\nFor `newSession`, `fork`, and `switchSession`:\n\n- Before event: `session_before_switch`\n - reasons: `new`, `fork`, `resume`\n - cancellable by returning `{ cancel: true }`\n- After event: `session_switch`\n - same reason set\n - includes `previousSessionFile`\n\n`ExtensionRunner.emit()` returns early on the first cancelling before-event result.\n\n### Custom tool `onSession` behavior\n\nSDK bridges extension session events to custom tool `onSession` callbacks:\n\n- `session_switch` -> `onSession({ reason: \"switch\", previousSessionFile })`\n- `session_branch` -> `reason: \"branch\"`\n- `session_start` -> `reason: \"start\"`\n- `session_tree` -> `reason: \"tree\"`\n- `session_shutdown` -> `reason: \"shutdown\"`\n\nThese callbacks are observational; they do not cancel switch/fork.\n\n### Other cancellation surfaces relevant to this doc\n\n- `/fork` is blocked while streaming (user must wait/abort current response first).\n- `/resume` selector can be cancelled by user closing selector.\n- Cross-project `--resume ` can be cancelled by declining fork prompt.\n- `/share` has UI abort path (`Share cancelled`) for gist flow; it does not wire process-kill semantics for `gh gist create` in this code path.\n\n## Non-persistent (in-memory) session behavior\n\nWhen session manager is created with `SessionManager.inMemory()` (`--no-session`):\n\n- Session file path is absent.\n- `/export` and `/share` fail with `Cannot export in-memory session to HTML` (propagated to command error UI).\n- `/fork` fails because `SessionManager.fork()` requires persistence.\n- `/dump` still works because it serializes in-memory agent state.\n- CLI resume/continue semantics are bypassed if `--no-session` is set, because manager creation returns in-memory immediately.\n\n## Known implementation caveats (as of current code)\n\n- `SelectorController.handleResumeSession()` does not check the boolean result from `session.switchSession(...)`; a hook-cancelled switch can still proceed through UI \"Resumed session\" repaint/status path.\n- `/share` custom-share failures do not degrade to default gist fallback; they terminate the command with error.\n- `/export` argument tokenization is simplistic and does not preserve quoted paths with spaces.\n", "session-switching-and-recent-listing.md": "# Session switching and recent session listing\n\nThis document describes how coding-agent discovers recent sessions, resolves `--resume` targets, presents session pickers, and switches the active runtime session.\n\nIt focuses on current implementation behavior, including fallback paths and caveats.\n\n## Implementation files\n\n- [`../src/session/session-manager.ts`](../packages/coding-agent/src/session/session-manager.ts)\n- [`../src/session/agent-session.ts`](../packages/coding-agent/src/session/agent-session.ts)\n- [`../src/cli/session-picker.ts`](../packages/coding-agent/src/cli/session-picker.ts)\n- [`../src/modes/components/session-selector.ts`](../packages/coding-agent/src/modes/components/session-selector.ts)\n- [`../src/modes/controllers/selector-controller.ts`](../packages/coding-agent/src/modes/controllers/selector-controller.ts)\n- [`../src/main.ts`](../packages/coding-agent/src/main.ts)\n- [`../src/sdk/session.ts`](../packages/coding-agent/src/sdk/session.ts)\n- [`../src/modes/interactive-mode.ts`](../packages/coding-agent/src/modes/interactive-mode.ts)\n- [`../src/modes/utils/ui-helpers.ts`](../packages/coding-agent/src/modes/utils/ui-helpers.ts)\n\n## Recent-session discovery\n\n### Directory scope\n\nThe default managed scope is `~/.gjc/agent/sessions/v2-/`, where the digest is derived from the native canonical workspace identity rather than a path-string substitution. It is collision-resistant, but the digest is not a public injective identity or an authentication credential. POSIX aliases and supported Windows local aliases for the same directory resolve to the same scope; UNC/network workspaces are rejected as unsupported.\n\n`SessionManager.list(cwd, sessionDir?)` reads the selected directory unless an explicit `sessionDir` is provided. The public readonly SDK API is `resolveManagedSessionScope()` followed by `listManagedSessionCandidates()` from `@gajae-code/coding-agent/sdk`; both are versioned by `SESSION_DIRECTORY_API_VERSION` (currently `1`). The resolver/listing API creates, migrates, and deletes nothing. Listing reports validated v2 and legacy candidates, invalid candidates, and a foreign count instead of treating arbitrary files as owned sessions.\n\nDefault writes are v2-only. Legacy discovery/migration is lazy, validates identity before use, and follows `session.directoryMigration` (`copy-retain` by default; `disabled` to opt out); no automatic legacy cleanup occurs.\n\n### Two listing paths with different payloads\n\nThere are two different listing pipelines:\n\n1. `getRecentSessions(sessionDir, limit)` (welcome/summary view)\n - Reads a bounded 4KB prefix plus bounded trailing v4 header patches from each file.\n - Parses header metadata, applicable tail patches, and the earliest user text preview.\n - Returns lightweight `RecentSessionInfo` with lazy `name` and `timeAgo` getters.\n - Sorts by file `mtime` descending.\n\n2. `SessionManager.list(...)` / `SessionManager.listAll()` (resume pickers and ID matching)\n - Reads a bounded 4KB prefix plus at most 16KB of trailing v4 header patches for file-backed sessions.\n - Builds `SessionInfo` objects from bounded metadata and preview extraction; buried patches outside the tail budget deliberately fall back to line-1 header metadata.\n - Drops sessions with zero `message` entries and sorts by `modified` descending.\n\n### Metadata fallback behavior\n\nFor recent summaries (`RecentSessionInfo`):\n\n- display name preference: `header.title` -> first user prompt -> `header.id` -> filename\n- name is truncated to 40 chars for compact displays\n- control characters/newlines are stripped/sanitized from title-derived names\n\nFor `SessionInfo` list entries:\n\n- `title` is `header.title` or latest compaction `shortSummary`\n- `firstMessage` is first user message text or `\"(no messages)\"`\n\n## `--continue` resolution and terminal breadcrumb preference\n\n`SessionManager.continueRecent(cwd, sessionDir?)` resolves the target in this order:\n\n1. Read terminal-scoped breadcrumb (`~/.gjc/agent/terminal-sessions/`)\n2. Validate breadcrumb:\n - current terminal can be identified\n - breadcrumb cwd matches current cwd (resolved path compare)\n - referenced file still exists\n3. If breadcrumb is invalid/missing, fall back to newest file by mtime in the session dir (`findMostRecentSession`)\n4. If none found, create a new session\n\nTerminal ID derivation prefers TTY path and falls back to env-based identifiers (`KITTY_WINDOW_ID`, `TMUX_PANE`, `TERM_SESSION_ID`, `WT_SESSION`).\n\nBreadcrumb writes are best-effort and non-fatal.\n\n## Startup-time resume target resolution (`main.ts`)\n\n### `--resume `\n\n`createSessionManager(...)` handles string-valued `--resume` in two modes:\n\n1. Path-like value (contains `/`, `\\\\`, or ends with `.jsonl`)\n - direct `SessionManager.open(sessionArg, parsed.sessionDir)`\n\n2. ID prefix value\n - find match in `SessionManager.list(cwd, sessionDir)` by `id.startsWith(sessionArg)`\n - if no local match and `sessionDir` is not forced, try `SessionManager.listAll()`\n - first match is used (no ambiguity prompt)\n\nCross-project match behavior:\n\n- if matched session cwd differs from current cwd, CLI prompts whether to fork into current project\n- yes -> `SessionManager.forkFrom(...)`\n- no -> throws error (`Session \"...\" is in another project (...)`)\n\nNo match -> throws error (`Session \"...\" not found.`).\n\n### `--resume` (no value)\n\nHandled after initial session-manager construction:\n\n1. list local candidates through the bounded read-only resume-picker path\n2. if empty: print `No sessions found` and exit early\n3. open the TUI picker; cancellation returns silently and exits without writes\n4. inspect the selected transcript read-only and confirm resumable tail state when required\n5. strictly open the approved identity, rechecking ownership before any replay-sanitization persistence\n6. publish the terminal breadcrumb only after strict-open sanitation succeeds, then continue startup from the opened manager\n### `--continue`\n\nUses `SessionManager.continueRecent(...)` directly (breadcrumb-first behavior above).\n\n## Picker-based selection internals\n\n## CLI picker (`src/cli/session-picker.ts`)\n\n`selectSession(sessions)` creates a standalone TUI with `SessionSelectorComponent` and resolves exactly once:\n\n- selection -> resolves selected path\n- cancel (Esc) -> resolves `null`\n- hard exit (Ctrl+C path) -> stops TUI and `process.exit(0)`\n\n## Interactive in-session picker (`SelectorController.showSessionSelector`)\n\nFlow:\n\n1. fetch sessions from the current session directory via `SessionManager.listForResumePickerReadOnly(currentCwd, currentSessionDir)`\n2. mount `SessionSelectorComponent` in editor area using `showSelector(...)`\n3. callbacks:\n - select -> close selector and call `handleResumeSession(sessionPath)`\n - cancel -> restore editor and rerender\n - exit -> `ctx.shutdown()`\n\n## Session selector component behavior\n\n`SessionList` supports:\n\n- arrow/page navigation\n- Enter to select\n- Esc to cancel\n- Ctrl+C to exit\n- fuzzy search across session id/title/cwd/first message/all messages/path\n\nEmpty-list render behavior:\n\n- renders a message instead of crashing\n- Enter on empty does nothing (no callback)\n- Esc/Ctrl+C still work\n\nCaveat: UI text says `Press Tab to view all`, but this component currently has no Tab handler and current wiring only lists current-scope sessions.\n\n## Runtime switch execution (`AgentSession.switchSession`)\n\n`switchSession(sessionPath)` is the core in-process switch path.\n\nLifecycle/state transition:\n\n1. capture `previousSessionFile`\n2. emit `session_before_switch` hook event (`reason: \"resume\"`, cancellable)\n3. if canceled -> return `false` with no switch\n4. disconnect from current agent event stream\n5. abort active generation/tool flow\n6. clear queued steering/follow-up/next-turn message buffers\n7. flush session writer (`sessionManager.flush()`) to persist pending writes\n8. `sessionManager.setSessionFile(sessionPath)`\n - updates session file pointer\n - writes terminal breadcrumb\n - loads entries / migrates / blob-resolves / reindexes\n - if missing/invalid file data: initializes a new session at that path and rewrites header\n9. update `agent.sessionId`\n10. rebuild display context via `buildDisplaySessionContext()`\n11. restore persisted/discovered MCP tool selections and rebuild active tools/system prompt when discovery is enabled\n12. emit `session_switch` hook event (`reason: \"resume\"`, `previousSessionFile`)\n13. replace agent messages with rebuilt context and sync todos\n14. close provider sessions when switching to a different session or when same-session reload changed replay messages\n15. restore default model from `sessionContext.models.default` if available and present in model registry\n16. restore thinking level and service tier:\n - thinking uses persisted `thinking_level_change`, otherwise the configured default clamped to model capability\n - service tier uses persisted `service_tier_change`, otherwise the configured `serviceTier` setting (`\"none\"` becomes unset)\n17. reconnect agent listeners and return `true`\n\n## UI state rebuild after interactive switch\n\n`SelectorController.handleResumeSession` performs UI reset around `switchSession`:\n\n- stop loading animation\n- clear status container\n- clear pending-message UI and pending tool map\n- reset streaming component/message references\n- call `session.switchSession(...)`\n- clear chat container and rerender from session context (`renderInitialMessages`)\n- reload todos from new session artifacts\n- show `Resumed session`\n\nSo visible conversation/todo state is rebuilt from the new session file.\n\n## Startup resume vs in-session switch\n\n### Startup resume (`--continue`, `--resume`, direct open)\n\n- Session file is chosen before `createAgentSession(...)`.\n- `sdk.ts` builds `existingSession = sessionManager.buildSessionContext()`.\n- Agent messages are restored once during session creation.\n- Model/thinking are selected during creation (including restore/fallback logic).\n- Interactive mode then runs `#restoreModeFromSession()` to re-enter persisted mode state (currently plan/plan_paused).\n\n### In-session switch (`/resume`-style selector path)\n\n- Uses `AgentSession.switchSession(...)` on an already-running `AgentSession`.\n- Messages/model/thinking are rebuilt immediately in place.\n- Hook `session_before_switch`/`session_switch` events are emitted.\n- UI chat/todos are refreshed.\n- No dedicated post-switch mode restore call is made in selector flow; mode re-entry behavior is not symmetric with startup `#restoreModeFromSession()`.\n\n## Failure and edge-case behavior\n\n### Cancellation paths\n\n- CLI picker cancel -> returns `null`; bare resume exits silently without writes.\n- Interactive picker cancel -> editor restored, no session change.\n- Hook cancellation (`session_before_switch`) -> `switchSession()` returns `false`.\n\n### Empty list paths\n\n- CLI `--resume` (no value): empty list prints `No sessions found` and exits.\n- Interactive selector: empty list renders message and remains cancellable.\n\n### Missing/invalid target session file\n\nWhen opening/switching to a specific path (`setSessionFile`):\n\n- ENOENT -> treated as empty -> new session initialized at that exact path and persisted.\n- malformed/invalid header (or effectively unreadable parsed entries) -> treated as empty -> new session initialized and persisted.\n\nThis is recovery behavior, not hard failure.\n\n### Hard failures\n\nSwitch/open can still throw on true I/O failures (permission errors, rewrite failures, etc.), which propagate to callers.\n\n### ID prefix matching caveats\n\n- ID matching uses `startsWith` and takes first match in sorted list.\n- No ambiguity UI if multiple sessions share prefix.\n- `SessionManager.list(...)` excludes sessions with zero messages, so those sessions are not resumable via ID match/list picker.\n", diff --git a/packages/coding-agent/src/sdk/bus/index.ts b/packages/coding-agent/src/sdk/bus/index.ts index 9df6feeb32..7e65bc55d1 100644 --- a/packages/coding-agent/src/sdk/bus/index.ts +++ b/packages/coding-agent/src/sdk/bus/index.ts @@ -67,6 +67,7 @@ import { CursorRegistry, QueryHandlers, RevisionStore, type SessionSurface } fro import { projectQ10Models } from "../models.js"; import { PROMPT_CLIENT_REF_MAX_LENGTH, type SdkPromptTerminalOutcome } from "../prompt-status"; import { OPERATIONS } from "../protocol/operation-registry"; +import { ActiveProviderResolutionError } from "../providers.js"; import { lifecycleStartupCapabilityForApi, normalizeSdkStartupFailure, @@ -2101,6 +2102,13 @@ function sdkQuerySurface( promptTerminalOutcomeVersion: 1, }), getAuthProviders: () => [...new Set(ctx.modelRegistry.getAll().map(model => model.provider))], + getActiveProviders: () => { + try { + return ctx.modelRegistry.getActiveProviders(); + } catch { + throw new ActiveProviderResolutionError(); + } + }, getTools: () => { const tools = typeof (ctx as Partial).getAllTools === "function" ? ctx.getAllTools() : []; return tools.length > 0 ? tools : (getInstalledDefinitions("host_tools") ?? []); diff --git a/packages/coding-agent/src/sdk/host/query/handlers.ts b/packages/coding-agent/src/sdk/host/query/handlers.ts index 16a7e922a5..5f557b71f1 100644 --- a/packages/coding-agent/src/sdk/host/query/handlers.ts +++ b/packages/coding-agent/src/sdk/host/query/handlers.ts @@ -1,4 +1,6 @@ import { PROMPT_CLIENT_REF_MAX_LENGTH } from "../../prompt-status.js"; +import type { ActiveProviderDescriptor } from "../../providers.js"; +import { ActiveProviderResolutionError } from "../../providers.js"; import { assertCursorSelector, type CursorEnvelope, @@ -22,6 +24,7 @@ export interface SessionSurface { getUsage(): unknown | Promise; getModels(): unknown | Promise; getSkillState(): unknown | Promise; + getActiveProviders?(): ActiveProviderDescriptor[] | Promise; /** Q12 rows preserve workflow gate fields and include stable durable gate metadata. */ getGates(): unknown | Promise; getConfigItems(): unknown | Promise; @@ -102,6 +105,7 @@ const sources: Record 0) + return this.#error( + request, + "invalid_request", + false, + "providers.list/active does not accept input fields.", + ); + if (query === "Q29" && typeof this.surface.getActiveProviders !== "function") + return this.#error(request, "unavailable", false, "providers.list/active is unavailable for this session."); const source = sources[query]; if (!source) return this.#error(request, "invalid_request"); return await this.#pageSource(request, query, source); @@ -257,7 +271,12 @@ export class QueryHandlers { source.resource === "transcript" ? { highWatermark: cursor.highWatermark } : {}, ); } else { - snapshot = await (this.surface[source.method] as () => unknown)(); + try { + snapshot = await (this.surface[source.method] as () => unknown)(); + } catch (error) { + if (queryId === "Q29") throw new ActiveProviderResolutionError(); + throw error; + } revision = await this.revisions.createRevision(source.resource, resourceId, snapshot); } if (snapshot === undefined) return this.#error(request, "resource_gone"); diff --git a/packages/coding-agent/src/sdk/index.ts b/packages/coding-agent/src/sdk/index.ts index 14ff0b2ec4..3e53d1ac9d 100644 --- a/packages/coding-agent/src/sdk/index.ts +++ b/packages/coding-agent/src/sdk/index.ts @@ -22,5 +22,6 @@ export type { Q10ThinkingMode, } from "./models"; export * from "./prompt-status"; +export type { ActiveProviderConnectionKind, ActiveProviderDescriptor } from "./providers"; export * from "./session"; export * from "./session-directory"; diff --git a/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json b/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json index f7afaad6cd..e189b069cf 100644 --- a/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json +++ b/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json @@ -1583,6 +1583,24 @@ "packages/coding-agent/test/sdk-operation-inventory.test.ts" ] }, + { + "sourceId": "registry:Q29", + "sourceFile": "packages/coding-agent/src/sdk/protocol/operation-registry.ts", + "sourceKind": "registry", + "decision": "include", + "sdkId": "providers.list/active", + "adapterMappings": { + "telegram": "prohibited", + "discord": "prohibited", + "slack": "prohibited", + "mcp": "generic_safe", + "acp": "generic_safe", + "daemonCli": "generic_safe" + }, + "testIds": [ + "packages/coding-agent/test/sdk-operation-inventory.test.ts" + ] + }, { "sourceId": "registry:R01", "sourceFile": "packages/coding-agent/src/sdk/protocol/operation-registry.ts", diff --git a/packages/coding-agent/src/sdk/protocol/operation-registry.ts b/packages/coding-agent/src/sdk/protocol/operation-registry.ts index abc262a86c..7ce105e0a0 100644 --- a/packages/coding-agent/src/sdk/protocol/operation-registry.ts +++ b/packages/coding-agent/src/sdk/protocol/operation-registry.ts @@ -164,6 +164,7 @@ const queries = [ "skill.invoke_status", "Read the authoritative reconciliation status of a skill.invoke by command/turn IDs or clientRef.", ], + ["providers.list/active", "List active providers."], ] as const; const reverse = [ @@ -243,7 +244,7 @@ function queryContinuityClass(id: string): QueryContinuityClass { } function queryDisposition(id: string): Record { - if (["Q23", "Q24", "Q25", "Q26", "Q27", "Q28"].includes(id)) + if (["Q23", "Q24", "Q25", "Q26", "Q27", "Q28", "Q29"].includes(id)) return dispositions({ telegram: "prohibited", discord: "prohibited", slack: "prohibited" }); return dispositions(); } @@ -306,7 +307,9 @@ export const OPERATIONS: readonly Operation[] = [ errorCodes: id === "Q27" ? ["invalid_request", "resource_gone", "model_profile_registry_error"] - : ["invalid_request", "resource_gone"], + : id === "Q29" + ? ["invalid_request", "resource_gone", "internal"] + : ["invalid_request", "resource_gone"], continuityClass: queryContinuityClass(id), adapterDispositions: queryDisposition(id), testIds: ["packages/coding-agent/test/sdk-operation-inventory.test.ts"], diff --git a/packages/coding-agent/src/sdk/providers.ts b/packages/coding-agent/src/sdk/providers.ts new file mode 100644 index 0000000000..925db8579e --- /dev/null +++ b/packages/coding-agent/src/sdk/providers.ts @@ -0,0 +1,51 @@ +export type ActiveProviderConnectionKind = "credential" | "credentialless"; + +export interface ActiveProviderDescriptor { + provider: string; + connectionKind: ActiveProviderConnectionKind; +} +function compareProviderIdsByUtf8(left: string, right: string): number { + const leftBytes = new TextEncoder().encode(left); + const rightBytes = new TextEncoder().encode(right); + const length = Math.min(leftBytes.length, rightBytes.length); + for (let index = 0; index < length; index += 1) { + const difference = leftBytes[index]! - rightBytes[index]!; + if (difference !== 0) return difference; + } + return leftBytes.length - rightBytes.length; +} + +/** + * Project active-provider inputs to the exact public DTO shape. + * + * Connection credentials take precedence when the same provider is reported + * more than once. Provider IDs are sorted by their UTF-8 bytes without + * normalization. + */ +export function projectActiveProviderDescriptors( + descriptors: readonly { provider: string; connectionKind: unknown }[], +): ActiveProviderDescriptor[] { + const active = new Map(); + for (const descriptor of descriptors) { + if (descriptor.connectionKind !== "credential" && descriptor.connectionKind !== "credentialless") { + throw new Error("Invalid active provider connection kind."); + } + if (active.get(descriptor.provider) === "credential") continue; + active.set(descriptor.provider, descriptor.connectionKind); + } + return [...active.entries()] + .sort(([left], [right]) => compareProviderIdsByUtf8(left, right)) + .map(([provider, connectionKind]) => ({ provider, connectionKind })); +} + +export const ACTIVE_PROVIDER_RESOLUTION_ERROR_CODE = "internal" as const; +export const ACTIVE_PROVIDER_RESOLUTION_ERROR_MESSAGE = "Unable to resolve active providers."; + +export class ActiveProviderResolutionError extends Error { + readonly code = ACTIVE_PROVIDER_RESOLUTION_ERROR_CODE; + + constructor() { + super(ACTIVE_PROVIDER_RESOLUTION_ERROR_MESSAGE); + this.name = "ActiveProviderResolutionError"; + } +} diff --git a/packages/coding-agent/test/manifests/sdk-adapter-parity-v1.json b/packages/coding-agent/test/manifests/sdk-adapter-parity-v1.json index 24808696ac..8c3d223b76 100644 --- a/packages/coding-agent/test/manifests/sdk-adapter-parity-v1.json +++ b/packages/coding-agent/test/manifests/sdk-adapter-parity-v1.json @@ -8651,6 +8651,102 @@ ], "expected": "forwarded" }, + { + "adapterTestId": "AD-T-Q29", + "sdkId": "providers.list/active", + "adapter": "telegram", + "disposition": "prohibited", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "testNamePattern": "AD-T-Q29", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "--test-name-pattern", + "^AD-T-Q29:" + ], + "expected": "rejected_before_send" + }, + { + "adapterTestId": "AD-D-Q29", + "sdkId": "providers.list/active", + "adapter": "discord", + "disposition": "prohibited", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "testNamePattern": "AD-D-Q29", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "--test-name-pattern", + "^AD-D-Q29:" + ], + "expected": "rejected_before_send" + }, + { + "adapterTestId": "AD-S-Q29", + "sdkId": "providers.list/active", + "adapter": "slack", + "disposition": "prohibited", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "testNamePattern": "AD-S-Q29", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "--test-name-pattern", + "^AD-S-Q29:" + ], + "expected": "rejected_before_send" + }, + { + "adapterTestId": "AD-M-Q29", + "sdkId": "providers.list/active", + "adapter": "mcp", + "disposition": "generic_safe", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "testNamePattern": "AD-M-Q29", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "--test-name-pattern", + "^AD-M-Q29:" + ], + "expected": "forwarded" + }, + { + "adapterTestId": "AD-A-Q29", + "sdkId": "providers.list/active", + "adapter": "acp", + "disposition": "generic_safe", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "testNamePattern": "AD-A-Q29", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "--test-name-pattern", + "^AD-A-Q29:" + ], + "expected": "forwarded" + }, + { + "adapterTestId": "AD-L-Q29", + "sdkId": "providers.list/active", + "adapter": "daemonCli", + "disposition": "generic_safe", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "testNamePattern": "AD-L-Q29", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "--test-name-pattern", + "^AD-L-Q29:" + ], + "expected": "forwarded" + }, { "adapterTestId": "AD-T-R01", "sdkId": "terminal.create/output/release/wait", diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 22644717a8..68dda3e830 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -2,7 +2,17 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { Effort, type Model, type OpenAICompat, type ThinkingConfig, writeModelCache } from "@gajae-code/ai"; +import { + type Api, + type Context, + Effort, + type Model, + type OpenAICompat, + readModelCache, + type ThinkingConfig, + writeModelCache, +} from "@gajae-code/ai"; +import { streamOpenAICompletions } from "@gajae-code/ai/providers/openai-completions"; import { kNoAuth, MODEL_ROLE_IDS, ModelRegistry } from "@gajae-code/coding-agent/config/model-registry"; import { type ModelLookupRegistry, @@ -250,6 +260,24 @@ describe("ModelRegistry", () => { restore(); } }); + test("reloads bundled OpenAI models when OPENAI_BASE_URL changes without a models config", async () => { + const restore = setEnvForTest("OPENAI_BASE_URL", "https://openai-first.example.com/v1"); + try { + const registry = new ModelRegistry(authStorage, modelsJsonPath); + expect( + getModelsForProvider(registry, "openai").every(model => model.baseUrl === Bun.env.OPENAI_BASE_URL), + ).toBe(true); + + Bun.env.OPENAI_BASE_URL = "https://openai-second.example.com/v1"; + await registry.refresh("offline"); + + expect( + getModelsForProvider(registry, "openai").every(model => model.baseUrl === Bun.env.OPENAI_BASE_URL), + ).toBe(true); + } finally { + restore(); + } + }); test("does not apply OPENAI_BASE_URL to OpenAI Codex models", () => { const restore = setEnvForTest("OPENAI_BASE_URL", "https://openai-proxy.example.com/v1"); @@ -855,6 +883,147 @@ describe("ModelRegistry", () => { else process.env.XAI_API_KEY = previous; } }); + test("keeps normal availability while excluding a failed stored command key from Q29", async () => { + const restoreXaiKey = unsetEnvForTest("XAI_API_KEY"); + try { + authStorage.close(); + authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"), { + configValueResolver: async () => undefined, + }); + await authStorage.set("xai", [{ type: "api_key", key: "!missing-xai-key" }]); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const initial = registry.getAvailable(); + expect(initial.some(model => model.provider === "xai")).toBe(true); + expect(registry.getActiveProviders().some(provider => provider.provider === "xai")).toBe(false); + + await expect(authStorage.peekApiKey("xai")).resolves.toBeUndefined(); + + const available = registry.getAvailable(); + expect(available).not.toBe(initial); + expect(available.some(model => model.provider === "xai")).toBe(true); + expect(registry.getActiveProviders().some(provider => provider.provider === "xai")).toBe(false); + } finally { + restoreXaiKey(); + } + }); + test("recovers a failed stored command key through ordinary provider key lookup", async () => { + const restoreXaiKey = unsetEnvForTest("XAI_API_KEY"); + let resolvedKey: string | undefined; + try { + authStorage.close(); + authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"), { + configValueResolver: async config => (config === "!recovering-xai-key" ? resolvedKey : undefined), + }); + await authStorage.set("xai", [{ type: "api_key", key: "!recovering-xai-key" }]); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await expect(authStorage.peekApiKey("xai")).resolves.toBeUndefined(); + expect(registry.getActiveProviders().some(provider => provider.provider === "xai")).toBe(false); + + resolvedKey = "recovered-xai-key"; + await expect(registry.getApiKeyForProvider("xai")).resolves.toBe("recovered-xai-key"); + expect(registry.getActiveProviders()).toContainEqual({ + provider: "xai", + connectionKind: "credential", + }); + } finally { + restoreXaiKey(); + } + }); + test("keeps normal availability after every stored command key resolves undefined", async () => { + const restoreXaiKey = unsetEnvForTest("XAI_API_KEY"); + try { + authStorage.close(); + authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"), { + configValueResolver: async () => undefined, + }); + await authStorage.set("xai", [ + { type: "api_key", key: "!missing-xai-key-a" }, + { type: "api_key", key: "!missing-xai-key-b" }, + ]); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const initial = registry.getAvailable(); + expect(initial.some(model => model.provider === "xai")).toBe(true); + + await expect(authStorage.peekApiKey("xai")).resolves.toBeUndefined(); + const afterFirst = registry.getAvailable(); + expect(afterFirst.some(model => model.provider === "xai")).toBe(true); + + await expect(authStorage.peekApiKey("xai")).resolves.toBeUndefined(); + const available = registry.getAvailable(); + expect(available).not.toBe(afterFirst); + expect(available.some(model => model.provider === "xai")).toBe(true); + expect(registry.getActiveProviders().some(provider => provider.provider === "xai")).toBe(false); + } finally { + restoreXaiKey(); + } + }); + test("preserves mixed-credential and selector auth precedence after stored API-key resolution", async () => { + const restoreXaiKey = unsetEnvForTest("XAI_API_KEY"); + try { + authStorage.close(); + authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"), { + configValueResolver: async () => undefined, + }); + await authStorage.set("xai", [ + { type: "api_key", key: "!missing-xai-key" }, + { + type: "oauth", + access: "selected-access", + refresh: "selected-refresh", + expires: Date.now() + 60_000, + email: "selected@example.com", + }, + ]); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + expect(registry.getAvailable().some(model => model.provider === "xai")).toBe(true); + await expect(authStorage.peekApiKey("xai")).resolves.toBeUndefined(); + expect(registry.getAvailable().some(model => model.provider === "xai")).toBe(true); + expect(registry.getActiveProviders().some(provider => provider.provider === "xai")).toBe(false); + + authStorage.setRuntimeCredentialSelector("xai", { + kind: "email", + value: "selected@example.com", + }); + expect(registry.getAvailable().some(model => model.provider === "xai")).toBe(true); + + authStorage.removeRuntimeCredentialSelector("xai"); + authStorage.setRuntimeApiKey("xai", "runtime-test-key"); + expect(registry.getAvailable().some(model => model.provider === "xai")).toBe(true); + } finally { + restoreXaiKey(); + } + }); + test("rejects a dangling credential selector even when a runtime API-key override exists", async () => { + const previous = process.env.XAI_API_KEY; + delete process.env.XAI_API_KEY; + try { + await authStorage.set("xai", [ + { + type: "oauth", + access: "selected-access", + refresh: "selected-refresh", + expires: Date.now() + 60_000, + email: "selected@example.com", + }, + ]); + authStorage.setRuntimeCredentialSelector("xai", { + kind: "email", + value: "selected@example.com", + }); + await authStorage.set("xai", []); + authStorage.setRuntimeApiKey("xai", "runtime-test-key"); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + expect(registry.getAvailable().some(model => model.provider === "xai")).toBe(false); + } finally { + if (previous === undefined) delete process.env.XAI_API_KEY; + else process.env.XAI_API_KEY = previous; + } + }); test("refreshes available models when an API-key environment variable changes", async () => { await Settings.init({ inMemory: true }); @@ -872,6 +1041,56 @@ describe("ModelRegistry", () => { else process.env.XAI_API_KEY = previous; } }); + test("refresh reloads custom apiKeyEnv presence changes without a models file change", async () => { + const keyEnv = `GJC_TEST_REFRESH_PROVIDER_KEY_${Snowflake.next()}`; + const restoreKey = unsetEnvForTest(keyEnv); + try { + writeRawModelsJson({ + "env-provider": { + baseUrl: "https://env-provider.example/v1", + api: "openai-responses", + apiKeyEnv: keyEnv, + models: [{ id: "env-model" }], + }, + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + expect(registry.getAvailable().some(model => model.provider === "env-provider")).toBe(false); + + Bun.env[keyEnv] = "refresh-env-key"; + await registry.refresh("offline"); + expect(registry.getAvailable().some(model => model.provider === "env-provider")).toBe(true); + await expect(registry.getApiKeyForProvider("env-provider")).resolves.toBe("refresh-env-key"); + + delete Bun.env[keyEnv]; + await registry.refresh("offline"); + expect(registry.getAvailable().some(model => model.provider === "env-provider")).toBe(false); + await expect(registry.getApiKeyForProvider("env-provider")).resolves.toBeUndefined(); + } finally { + restoreKey(); + } + }); + test("refresh reloads custom apiKey environment-name values without a models file change", async () => { + const keyEnv = `GJC_TEST_REFRESH_PROVIDER_API_KEY_${Snowflake.next()}`; + const restoreKey = setEnvForTest(keyEnv, "initial-env-key"); + try { + writeRawModelsJson({ + "api-key-provider": { + baseUrl: "https://api-key-provider.example/v1", + api: "openai-responses", + apiKey: keyEnv, + models: [{ id: "api-key-model" }], + }, + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + await expect(registry.getApiKeyForProvider("api-key-provider")).resolves.toBe("initial-env-key"); + Bun.env[keyEnv] = "rotated-env-key"; + await registry.refresh("offline"); + await expect(registry.getApiKeyForProvider("api-key-provider")).resolves.toBe("rotated-env-key"); + } finally { + restoreKey(); + } + }); test("keeps a session canonical variant while it remains available", () => { const registry = new ModelRegistry(authStorage, modelsJsonPath); @@ -2200,6 +2419,8 @@ describe("ModelRegistry", () => { expect(registry.getAvailable().some(model => model.provider === "github-copilot")).toBe(false); expect(registry.getDiscoverableProviders()).not.toContain("ollama"); + expect(registry.getActiveProviders().some(provider => provider.provider === "github-copilot")).toBe(false); + expect(registry.getActiveProviders().some(provider => provider.provider === "ollama")).toBe(false); }); test("refresh skips discovery probes for disabled local providers", async () => { @@ -2223,11 +2444,63 @@ describe("ModelRegistry", () => { ); expect(disabledProbeUrls).toEqual([]); }); + test("rebuilds implicit discovery when disabled providers change without models.json", async () => { + await Settings.init({ + inMemory: true, + overrides: { + disabledProviders: ["llama.cpp", "lm-studio", "ollama"], + }, + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + expect(registry.getDiscoverableProviders()).not.toContain("ollama"); + + settings.override("disabledProviders", []); + await registry.refresh("offline"); + + expect(registry.getDiscoverableProviders()).toContain("ollama"); + }); + test("rebuilds implicit discovery when endpoint environment changes without models.json", async () => { + const firstBaseUrl = "http://127.0.0.1:21334"; + const secondBaseUrl = "http://127.0.0.1:21434"; + const requestedUrls: string[] = []; + using _hook = hookFetch((input, init, next) => { + const url = String(input); + if (url === `${firstBaseUrl}/api/tags` || url === `${secondBaseUrl}/api/tags`) { + requestedUrls.push(url); + return new Response(JSON.stringify({ models: [{ name: "phi4-mini" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === `${firstBaseUrl}/api/show` || url === `${secondBaseUrl}/api/show`) { + requestedUrls.push(url); + return new Response(JSON.stringify({ capabilities: ["completion"] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return next(input, init); + }); + + const restoreInitialBaseUrl = setEnvForTest("OLLAMA_BASE_URL", firstBaseUrl); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const firstRefresh = registry.refreshProvider("ollama", "online"); + restoreInitialBaseUrl(); + await firstRefresh; + const restoreChangedBaseUrl = setEnvForTest("OLLAMA_BASE_URL", secondBaseUrl); + const refresh = registry.refreshProvider("ollama", "online"); + restoreChangedBaseUrl(); + await refresh; + + expect(requestedUrls).toContain(`${firstBaseUrl}/api/tags`); + expect(requestedUrls).toContain(`${secondBaseUrl}/api/tags`); + }); }); describe("runtime discovery", () => { test("auto-discovers ollama models without provider config", async () => { using _hook = mockOllamaDiscovery(["phi4-mini"]); - const _restoreOllamaKey = unsetEnvForTest("OLLAMA_API_KEY"); + const restoreOllamaBaseUrl = setEnvForTest("OLLAMA_BASE_URL", "http://127.0.0.1:11434"); + const restoreOllamaKey = unsetEnvForTest("OLLAMA_API_KEY"); try { const registry = new ModelRegistry(authStorage, modelsJsonPath); await registry.refresh(); @@ -2235,8 +2508,43 @@ describe("ModelRegistry", () => { expect(ollamaModels.some(m => m.id === "phi4-mini")).toBe(true); expect(registry.getAvailable().some(m => m.provider === "ollama" && m.id === "phi4-mini")).toBe(true); expect(await registry.getApiKey(ollamaModels[0])).toBe(kNoAuth); + expect(registry.getActiveProviders()).toContainEqual({ + provider: "ollama", + connectionKind: "credentialless", + }); } finally { - _restoreOllamaKey(); + restoreOllamaKey(); + restoreOllamaBaseUrl(); + } + }); + test("uses credentials for implicit Ollama discovery and model requests", async () => { + const restoreOllamaKey = setEnvForTest("OLLAMA_API_KEY", "implicit-ollama-key"); + using _hook = hookFetch((input, init) => { + const url = String(input); + if (url === "http://127.0.0.1:11434/api/tags") { + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer implicit-ollama-key"); + return new Response(JSON.stringify({ models: [{ name: "phi4-mini" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "http://127.0.0.1:11434/api/show") { + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer implicit-ollama-key"); + return new Response(JSON.stringify({ capabilities: ["completion"] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + try { + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refresh(); + + const ollamaModel = getModelsForProvider(registry, "ollama")[0]; + expect(await registry.getApiKey(ollamaModel)).toBe("implicit-ollama-key"); + } finally { + restoreOllamaKey(); } }); test("discovers ollama-cloud through built-in descriptor flow without regressing local implicit ollama", async () => { @@ -2692,99 +3000,569 @@ describe("ModelRegistry", () => { const apiKey = await registry.getApiKey(llamaModels[0]); expect(apiKey).toBe(kNoAuth); }); - test("llama.cpp discovery reads context window from props n_ctx", async () => { - using _hook = hookFetch(input => { - const url = String(input); - if (url === "http://127.0.0.1:8080/models") { - return new Response(JSON.stringify({ data: [{ id: "qwen35-35b-a3b" }] }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - if (url === "http://127.0.0.1:8080/props") { - return new Response( - JSON.stringify({ - default_generation_settings: { - n_ctx: 262144, - }, - modalities: { - vision: true, - audio: false, - }, - }), - { + test("llama.cpp implicit optional auth rechecks credentials added after startup", async () => { + const restoreLlamaKey = unsetEnvForTest("LLAMA_CPP_API_KEY"); + const restoreLlamaBaseUrl = unsetEnvForTest("LLAMA_CPP_BASE_URL"); + try { + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + authStorage.setRuntimeApiKey("llama.cpp", "added-after-startup-key"); + using _hook = hookFetch((input, init) => { + const url = String(input); + if (url === "http://127.0.0.1:8080/models" || url === "http://127.0.0.1:8080/props") { + const headers = new Headers(init?.headers); + expect(headers.get("Authorization")).toBe("Bearer added-after-startup-key"); + if (url.endsWith("/props")) { + return new Response(JSON.stringify({ default_generation_settings: { n_ctx: 65536 } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({ data: [{ id: "Q29-llama-model" }] }), { status: 200, headers: { "Content-Type": "application/json" }, - }, - ); - } - throw new Error(`Unexpected URL: ${url}`); - }); - const registry = new ModelRegistry(authStorage, modelsJsonPath); - await registry.refresh(); - const llama = registry.find("llama.cpp", "qwen35-35b-a3b"); - expect(llama?.contextWindow).toBe(262144); - expect(llama?.maxTokens).toBe(8192); - expect(llama?.input).toEqual(["text", "image"]); - }); - }); - describe("bundled Anthropic catalog availability", () => { - test("includes native Opus 4.7 in available models when Anthropic auth exists", async () => { - await authStorage.set("anthropic", [{ type: "api_key", key: "sk-ant-api-test" }]); - - const registry = new ModelRegistry(authStorage, modelsJsonPath); - await registry.refresh("offline"); - - expect( - registry.getAvailable().some(model => model.provider === "anthropic" && model.id === "claude-opus-4-7"), - ).toBe(true); - }); - }); - describe("disableStrictTools", () => { - test("custom provider with models gets disableStrictTools merged into compat", () => { - writeRawModelsJson({ - "bedrock-anthropic": { - baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/anthropic", - apiKey: "TEST_KEY", - api: "anthropic-messages", - disableStrictTools: true, - models: [ - { - id: "claude-sonnet-4-20250514", - name: "Claude Sonnet 4", - reasoning: false, - input: ["text", "image"], - cost: { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75 }, - contextWindow: 200000, - maxTokens: 16384, - }, - ], - }, - }); + }); + } + throw new Error(`Unexpected URL: ${url}`); + }); - const registry = new ModelRegistry(authStorage, modelsJsonPath); - const model = registry.find("bedrock-anthropic", "claude-sonnet-4-20250514"); + await registry.refresh(); - expect(model).toBeDefined(); - expect((model?.compat as { disableStrictTools?: boolean } | undefined)?.disableStrictTools).toBe(true); + expect(registry.find("llama.cpp", "Q29-llama-model")).toBeDefined(); + expect(registry.getActiveProviders()).toContainEqual({ + provider: "llama.cpp", + connectionKind: "credential", + }); + } finally { + restoreLlamaBaseUrl(); + restoreLlamaKey(); + } }); + test("llama.cpp implicit optional auth falls back to credentialless discovery when stored auth is unusable", async () => { + const restoreLlamaKey = unsetEnvForTest("LLAMA_CPP_API_KEY"); + const restoreLlamaBaseUrl = unsetEnvForTest("LLAMA_CPP_BASE_URL"); + try { + await authStorage.set("llama.cpp", [ + { + type: "oauth", + access: "expired-llama-access", + refresh: "expired-llama-refresh", + expires: Date.now() - 60_000, + email: "llama@example.com", + }, + ]); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + expect(await authStorage.peekApiKey("llama.cpp")).toBeUndefined(); - test("disableStrictTools on override-only provider applies to built-in models", () => { - writeRawModelsJson({ anthropic: { disableStrictTools: true } }); + using _hook = hookFetch((input, init) => { + const url = String(input); + if (url === "http://127.0.0.1:8080/models" || url === "http://127.0.0.1:8080/props") { + expect(new Headers(init?.headers).get("Authorization")).toBeNull(); + if (url.endsWith("/props")) { + return new Response(JSON.stringify({ default_generation_settings: { n_ctx: 65536 } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({ data: [{ id: "Q29-llama-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected URL: ${url}`); + }); - const registry = new ModelRegistry(authStorage, modelsJsonPath); - const models = getModelsForProvider(registry, "anthropic"); + await registry.refresh(); - expect(models.length).toBeGreaterThan(0); - for (const model of models) { - expect((model.compat as { disableStrictTools?: boolean } | undefined)?.disableStrictTools).toBe(true); + expect(registry.find("llama.cpp", "Q29-llama-model")).toBeDefined(); + expect(registry.getActiveProviders()).toContainEqual({ + provider: "llama.cpp", + connectionKind: "credentialless", + }); + } finally { + restoreLlamaBaseUrl(); + restoreLlamaKey(); + } + }); + test("llama.cpp optional-auth fallback follows credential evidence without a second discovery refresh", async () => { + const restoreLlamaKey = unsetEnvForTest("LLAMA_CPP_API_KEY"); + const restoreLlamaBaseUrl = unsetEnvForTest("LLAMA_CPP_BASE_URL"); + try { + authStorage.close(); + authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"), { + configValueResolver: async () => undefined, + }); + let unavailable = false; + await authStorage.set("llama.cpp", [{ type: "api_key", key: "!missing-llama-key" }]); + const requestApiKeys: string[] = []; + using _hook = hookFetch((input, init) => { + const url = String(input); + if (unavailable) return new Response("unavailable", { status: 503 }); + if (url === "http://127.0.0.1:8080/models" || url === "http://127.0.0.1:8080/props") { + requestApiKeys.push(new Headers(init?.headers).get("Authorization") ?? ""); + if (url.endsWith("/props")) { + return new Response(JSON.stringify({ default_generation_settings: { n_ctx: 65536 } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({ data: [{ id: "Q29-fallback-llama-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("llama.cpp", "online"); + + const activeLlama = () => + registry.getActiveProviders().filter(provider => provider.provider === "llama.cpp"); + expect(activeLlama()).toEqual([{ provider: "llama.cpp", connectionKind: "credentialless" }]); + await expect(registry.getApiKeyForProvider("llama.cpp")).resolves.toBe(kNoAuth); + unavailable = true; + await registry.refreshProvider("llama.cpp", "online"); + expect(registry.getProviderDiscoveryState("llama.cpp")?.status).toBe("cached"); + expect(activeLlama()).toEqual([]); + + unavailable = false; + + authStorage.setRuntimeApiKey("llama.cpp", "added-after-fallback-key"); + + expect(activeLlama()).toEqual([]); + await expect(registry.getApiKeyForProvider("llama.cpp")).resolves.toBe("added-after-fallback-key"); + + await registry.refreshProvider("llama.cpp", "online"); + + expect(requestApiKeys).toEqual([ + "", + "", + "Bearer added-after-fallback-key", + "Bearer added-after-fallback-key", + ]); + expect(registry.find("llama.cpp", "Q29-fallback-llama-model")).toBeDefined(); + expect(activeLlama()).toEqual([{ provider: "llama.cpp", connectionKind: "credential" }]); + } finally { + restoreLlamaBaseUrl(); + restoreLlamaKey(); + } + }); + test("llama.cpp optional-auth preflight retries a recovered command credential", async () => { + const restoreLlamaKey = unsetEnvForTest("LLAMA_CPP_API_KEY"); + const restoreLlamaBaseUrl = unsetEnvForTest("LLAMA_CPP_BASE_URL"); + let resolvedKey: string | undefined; + try { + authStorage.close(); + authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"), { + configValueResolver: async config => (config === "!recovering-llama-key" ? resolvedKey : undefined), + }); + await authStorage.set("llama.cpp", [{ type: "api_key", key: "!recovering-llama-key" }]); + const requestApiKeys: string[] = []; + using _hook = hookFetch((input, init) => { + const url = String(input); + if (url === "http://127.0.0.1:8080/models" || url === "http://127.0.0.1:8080/props") { + requestApiKeys.push(new Headers(init?.headers).get("Authorization") ?? ""); + if (url.endsWith("/props")) { + return new Response(JSON.stringify({ default_generation_settings: { n_ctx: 65536 } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({ data: [{ id: "recovered-llama-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("llama.cpp", "online"); + expect(registry.getActiveProviders()).toContainEqual({ + provider: "llama.cpp", + connectionKind: "credentialless", + }); + await expect( + registry.getApiKeyForProvider("llama.cpp", undefined, undefined, { + credentialSelector: { kind: "email", value: "missing@example.com" }, + }), + ).rejects.toThrow("No credential found"); + + resolvedKey = "recovered-llama-key"; + await expect(registry.getApiKeyForProvider("llama.cpp")).resolves.toBe("recovered-llama-key"); + await registry.refreshProvider("llama.cpp", "online"); + + expect(requestApiKeys).toEqual(["", "", "Bearer recovered-llama-key", "Bearer recovered-llama-key"]); + expect(registry.getActiveProviders()).toContainEqual({ + provider: "llama.cpp", + connectionKind: "credential", + }); + } finally { + restoreLlamaBaseUrl(); + restoreLlamaKey(); + } + }); + test("llama.cpp implicit optional auth reuses the preflight credential for discovery", async () => { + const restoreLlamaKey = unsetEnvForTest("LLAMA_CPP_API_KEY"); + const restoreLlamaBaseUrl = unsetEnvForTest("LLAMA_CPP_BASE_URL"); + try { + authStorage.close(); + authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"), { + configValueResolver: async config => (config === "!working-llama-key" ? "working-llama-key" : undefined), + }); + await authStorage.set("llama.cpp", [ + { type: "api_key", key: "!working-llama-key" }, + { type: "api_key", key: "!dangling-llama-key" }, + ]); + + const requestApiKeys: string[] = []; + using _hook = hookFetch((input, init) => { + const url = String(input); + if (url === "http://127.0.0.1:8080/models" || url === "http://127.0.0.1:8080/props") { + requestApiKeys.push(new Headers(init?.headers).get("Authorization") ?? ""); + if (url.endsWith("/props")) { + return new Response(JSON.stringify({ default_generation_settings: { n_ctx: 65536 } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({ data: [{ id: "preflight-llama-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refresh(); + + expect(requestApiKeys).toEqual(["Bearer working-llama-key", "Bearer working-llama-key"]); + expect(registry.find("llama.cpp", "preflight-llama-model")).toBeDefined(); + expect(registry.getProviderDiscoveryState("llama.cpp")?.status).toBe("ok"); + expect(registry.getActiveProviders()).toContainEqual({ + provider: "llama.cpp", + connectionKind: "credential", + }); + } finally { + restoreLlamaBaseUrl(); + restoreLlamaKey(); + } + }); + test("llama.cpp optional-auth preflight uses a refresh-aware OAuth credential for discovery", async () => { + const restoreLlamaKey = unsetEnvForTest("LLAMA_CPP_API_KEY"); + const restoreLlamaBaseUrl = unsetEnvForTest("LLAMA_CPP_BASE_URL"); + try { + await authStorage.set("llama.cpp", [ + { + type: "oauth", + access: "expiring-llama-access", + refresh: "refresh-llama-access", + expires: Date.now() + 30_000, + email: "llama@example.com", + }, + ]); + const getApiKeySpy = vi.spyOn(authStorage, "getApiKey").mockResolvedValue("refreshed-llama-access"); + const requestApiKeys: string[] = []; + using _hook = hookFetch((input, init) => { + const url = String(input); + if (url === "http://127.0.0.1:8080/models" || url === "http://127.0.0.1:8080/props") { + requestApiKeys.push(new Headers(init?.headers).get("Authorization") ?? ""); + if (url.endsWith("/props")) { + return new Response(JSON.stringify({ default_generation_settings: { n_ctx: 65536 } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({ data: [{ id: "refresh-aware-llama-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + try { + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("llama.cpp", "online"); + + expect(getApiKeySpy).toHaveBeenCalledWith("llama.cpp", undefined, { + baseUrl: "http://127.0.0.1:8080", + }); + expect(requestApiKeys).toEqual(["Bearer refreshed-llama-access", "Bearer refreshed-llama-access"]); + } finally { + getApiKeySpy.mockRestore(); + } + } finally { + restoreLlamaBaseUrl(); + restoreLlamaKey(); + } + }); + test("does not retain credentialless fallback after optional OAuth preflight failure", async () => { + const restoreLlamaKey = unsetEnvForTest("LLAMA_CPP_API_KEY"); + const restoreLlamaBaseUrl = unsetEnvForTest("LLAMA_CPP_BASE_URL"); + try { + await authStorage.set("llama.cpp", [ + { + type: "oauth", + access: "expiring-llama-access", + refresh: "refresh-llama-access", + expires: Date.now() + 30_000, + email: "llama@example.com", + }, + ]); + const getApiKeySpy = vi + .spyOn(authStorage, "getApiKey") + .mockRejectedValueOnce(new Error("transient refresh failure")) + .mockResolvedValue("recovered-llama-access"); + try { + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("llama.cpp", "online"); + + expect(await registry.getApiKeyForProvider("llama.cpp")).toBe("recovered-llama-access"); + } finally { + getApiKeySpy.mockRestore(); + } + } finally { + restoreLlamaBaseUrl(); + restoreLlamaKey(); + } + }); + test("does not advertise optional discovery after its selected credential is removed", async () => { + const restoreLlamaKey = unsetEnvForTest("LLAMA_CPP_API_KEY"); + const restoreLlamaBaseUrl = unsetEnvForTest("LLAMA_CPP_BASE_URL"); + try { + await authStorage.set("llama.cpp", [ + { + type: "oauth", + access: "selected-llama-access", + refresh: "selected-llama-refresh", + expires: Date.now() + 60_000, + email: "selected@example.com", + }, + ]); + authStorage.setRuntimeCredentialSelector("llama.cpp", { + kind: "email", + value: "selected@example.com", + }); + await authStorage.set("llama.cpp", [{ type: "api_key", key: "other-llama-key" }]); + using _hook = hookFetch(() => { + throw new Error("optional discovery must not fall back after a selector failure"); + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + await registry.refreshProvider("llama.cpp", "online"); + + expect(registry.getActiveProviders().filter(provider => provider.provider === "llama.cpp")).toEqual([]); + await expect(registry.getApiKeyForProvider("llama.cpp")).rejects.toThrow("No credential found"); + } finally { + authStorage.removeRuntimeCredentialSelector("llama.cpp"); + restoreLlamaBaseUrl(); + restoreLlamaKey(); + } + }); + test("newer optional-auth preflight state wins when overlapping refreshes finish out of order", async () => { + const restoreLlamaKey = unsetEnvForTest("LLAMA_CPP_API_KEY"); + const restoreLlamaBaseUrl = unsetEnvForTest("LLAMA_CPP_BASE_URL"); + try { + await authStorage.set("llama.cpp", [ + { + type: "oauth", + access: "valid-llama-access", + refresh: "valid-llama-refresh", + expires: Date.now() + 60_000, + email: "llama@example.com", + }, + ]); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const olderCredential = Promise.withResolvers(); + const newerCredential = Promise.withResolvers(); + let credentialCalls = 0; + const credentialSpy = vi.spyOn(authStorage, "getApiKey").mockImplementation(async () => { + credentialCalls += 1; + return credentialCalls === 1 ? olderCredential.promise : newerCredential.promise; + }); + try { + const olderRefresh = registry.refreshProvider("llama.cpp", "offline"); + while (credentialCalls < 1) await Bun.sleep(0); + + const newerRefresh = registry.refreshProvider("llama.cpp", "offline"); + while (credentialCalls < 2) await Bun.sleep(0); + + newerCredential.resolve(undefined); + await newerRefresh; + expect(await registry.getApiKeyForProvider("llama.cpp")).toBe(kNoAuth); + + olderCredential.resolve("older-preflight-key"); + await olderRefresh; + expect(await registry.getApiKeyForProvider("llama.cpp")).toBe(kNoAuth); + } finally { + credentialSpy.mockRestore(); + } + } finally { + restoreLlamaBaseUrl(); + restoreLlamaKey(); + } + }); + test("credentialless OpenAI-compatible and llama.cpp discovery bypass dangling credential selectors", async () => { + writeRawModelsJson({ + "credentialless-openai": { + baseUrl: "https://credentialless-openai.example/v1", + api: "openai-completions", + auth: "none", + discovery: { type: "openai-models-list" }, + }, + "credentialless-llama": { + baseUrl: "https://credentialless-llama.example/v1", + api: "openai-completions", + auth: "none", + discovery: { type: "llama.cpp" }, + }, + }); + for (const provider of ["credentialless-openai", "credentialless-llama"]) { + await authStorage.set(provider, [ + { + type: "oauth", + access: "stale-access", + refresh: "stale-refresh", + expires: Date.now() + 60_000, + email: `${provider}@example.com`, + }, + ]); + authStorage.setRuntimeCredentialSelector(provider, { + kind: "email", + value: `${provider}@example.com`, + }); + await authStorage.set(provider, []); + } + + using _hook = hookFetch(input => { + const url = String(input); + if (url === "https://credentialless-openai.example/v1/models") { + return new Response(JSON.stringify({ data: [{ id: "openai-local-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "https://credentialless-llama.example/v1/models") { + return new Response(JSON.stringify({ data: [{ id: "llama-local-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "https://credentialless-llama.example/props") { + return new Response(JSON.stringify({ default_generation_settings: { n_ctx: 32768 } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + const getApiKey = vi.spyOn(authStorage, "getApiKey").mockRejectedValue(new Error("dangling selector")); + try { + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("credentialless-openai", "online"); + await registry.refreshProvider("credentialless-llama", "online"); + + expect(registry.find("credentialless-openai", "openai-local-model")).toBeDefined(); + expect(registry.find("credentialless-llama", "llama-local-model")).toBeDefined(); + expect(getApiKey).not.toHaveBeenCalled(); + } finally { + getApiKey.mockRestore(); + } + }); + test("llama.cpp discovery reads context window from props n_ctx", async () => { + using _hook = hookFetch(input => { + const url = String(input); + if (url === "http://127.0.0.1:8080/models") { + return new Response(JSON.stringify({ data: [{ id: "qwen35-35b-a3b" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "http://127.0.0.1:8080/props") { + return new Response( + JSON.stringify({ + default_generation_settings: { + n_ctx: 262144, + }, + modalities: { + vision: true, + audio: false, + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + } + throw new Error(`Unexpected URL: ${url}`); + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refresh(); + const llama = registry.find("llama.cpp", "qwen35-35b-a3b"); + expect(llama?.contextWindow).toBe(262144); + expect(llama?.maxTokens).toBe(8192); + expect(llama?.input).toEqual(["text", "image"]); + }); + }); + describe("bundled Anthropic catalog availability", () => { + test("includes native Opus 4.7 in available models when Anthropic auth exists", async () => { + await authStorage.set("anthropic", [{ type: "api_key", key: "sk-ant-api-test" }]); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refresh("offline"); + + expect( + registry.getAvailable().some(model => model.provider === "anthropic" && model.id === "claude-opus-4-7"), + ).toBe(true); + }); + }); + describe("disableStrictTools", () => { + test("custom provider with models gets disableStrictTools merged into compat", () => { + writeRawModelsJson({ + "bedrock-anthropic": { + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/anthropic", + apiKey: "TEST_KEY", + api: "anthropic-messages", + disableStrictTools: true, + models: [ + { + id: "claude-sonnet-4-20250514", + name: "Claude Sonnet 4", + reasoning: false, + input: ["text", "image"], + cost: { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75 }, + contextWindow: 200000, + maxTokens: 16384, + }, + ], + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const model = registry.find("bedrock-anthropic", "claude-sonnet-4-20250514"); + + expect(model).toBeDefined(); + expect((model?.compat as { disableStrictTools?: boolean } | undefined)?.disableStrictTools).toBe(true); + }); + + test("disableStrictTools on override-only provider applies to built-in models", () => { + writeRawModelsJson({ anthropic: { disableStrictTools: true } }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const models = getModelsForProvider(registry, "anthropic"); + + expect(models.length).toBeGreaterThan(0); + for (const model of models) { + expect((model.compat as { disableStrictTools?: boolean } | undefined)?.disableStrictTools).toBe(true); } }); - test("disableStrictTools is absent on built-in models without override", () => { - const registry = new ModelRegistry(authStorage, modelsJsonPath); - const models = getModelsForProvider(registry, "anthropic"); - + test("disableStrictTools is absent on built-in models without override", () => { + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const models = getModelsForProvider(registry, "anthropic"); + expect(models.length).toBeGreaterThan(0); for (const model of models) { expect((model.compat as { disableStrictTools?: boolean } | undefined)?.disableStrictTools).toBeUndefined(); @@ -3544,5 +4322,1935 @@ describe("ModelRegistry", () => { expect(getOpenAICompat(model)?.supportsStore).toBe(false); expect(await registry.getApiKeyForProvider("local")).toBe("LOCAL_TEST_KEY"); }); + test("uses stored credentials for OpenAI-compatible providers without inline auth", async () => { + await authStorage.set("local", [{ type: "api_key", key: "STORED_TEST_KEY" }]); + writeRawModelsJson({ + local: { + openaiCompat: { + baseUrl: "http://127.0.0.1:1234", + }, + }, + }); + using _hook = hookFetch((input, init) => { + expect(String(input)).toBe("http://127.0.0.1:1234/v1/models"); + expect((init?.headers as Record).Authorization).toBe("Bearer STORED_TEST_KEY"); + return new Response(JSON.stringify({ data: [{ id: "local-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refresh(); + + expect(registry.getActiveProviders()).toEqual([{ provider: "local", connectionKind: "credential" }]); + expect(await registry.getApiKeyForProvider("local")).toBe("STORED_TEST_KEY"); + await authStorage.set("local", []); + + expect(registry.find("local", "local-model")).toBeDefined(); + expect(registry.getActiveProviders()).toEqual([]); + }); + }); + describe("active provider resolution", () => { + const activeRowsFor = (registry: ModelRegistry, providerIds: readonly string[]) => { + const selected = new Set(providerIds); + return registry.getActiveProviders().filter(provider => selected.has(provider.provider)); + }; + test("rechecks non-fingerprinted environment credentials for active providers", () => { + const previous = process.env.GITLAB_TOKEN; + delete process.env.GITLAB_TOKEN; + try { + writeRawModelsJson({ + "gitlab-duo": { + baseUrl: "https://gitlab.example.com/v1", + api: "openai-completions", + models: [{ id: "duo-chat" }], + }, + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + expect(registry.getAvailable().some(model => model.provider === "gitlab-duo")).toBe(false); + expect(activeRowsFor(registry, ["gitlab-duo"])).toEqual([]); + + process.env.GITLAB_TOKEN = "gitlab-token"; + expect(activeRowsFor(registry, ["gitlab-duo"])).toEqual([ + { provider: "gitlab-duo", connectionKind: "credential" }, + ]); + + delete process.env.GITLAB_TOKEN; + expect(activeRowsFor(registry, ["gitlab-duo"])).toEqual([]); + } finally { + if (previous === undefined) delete process.env.GITLAB_TOKEN; + else process.env.GITLAB_TOKEN = previous; + } + }); + test("does not advertise a static optional provider after its selected credential is removed", async () => { + writeRawModelsJson({ + local: { + openaiCompat: { baseUrl: "http://127.0.0.1:1234" }, + models: [{ id: "static-local-model" }], + }, + }); + await authStorage.set("local", [ + { + type: "oauth", + access: "selected-local-access", + refresh: "selected-local-refresh", + expires: Date.now() + 60_000, + email: "selected@example.com", + }, + ]); + authStorage.setRuntimeCredentialSelector("local", { + kind: "email", + value: "selected@example.com", + }); + await authStorage.set("local", [{ type: "api_key", key: "other-local-key" }]); + try { + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + expect(activeRowsFor(registry, ["local"])).toEqual([]); + await expect(registry.getApiKeyForProvider("local")).rejects.toThrow("No credential found"); + } finally { + authStorage.removeRuntimeCredentialSelector("local"); + } + }); + test("keeps credentialless discovery active with an irrelevant dangling selector", async () => { + writeRawModelsJson({ + "credentialless-provider": { + baseUrl: "https://credentialless.example.com/v1", + api: "openai-responses", + auth: "none", + discovery: { type: "openai-models-list" }, + }, + }); + await authStorage.set("credentialless-provider", [ + { + type: "oauth", + access: "stale-access", + refresh: "stale-refresh", + expires: Date.now() + 60_000, + email: "stale@example.com", + }, + ]); + authStorage.setRuntimeCredentialSelector("credentialless-provider", { + kind: "email", + value: "stale@example.com", + }); + + using _hook = hookFetch(input => { + expect(String(input)).toBe("https://credentialless.example.com/v1/models"); + return new Response(JSON.stringify({ data: [{ id: "credentialless-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("credentialless-provider", "online"); + await authStorage.set("credentialless-provider", []); + + expect(registry.getActiveProviders()).toEqual([ + { provider: "credentialless-provider", connectionKind: "credentialless" }, + ]); + expect(registry.getAvailable().some(model => model.provider === "credentialless-provider")).toBe(true); + await expect(registry.getApiKeyForProvider("credentialless-provider")).resolves.toBe(kNoAuth); + }); + test("resolves active providers from credentials and configured credentialless models without I/O", () => { + writeRawModelsJson({ + "zeta.provider": { + baseUrl: "https://zeta.example.com/v1", + api: "openai-responses", + apiKey: "ZETA_KEY", + models: [{ id: "zeta-model" }], + }, + "alpha-provider": { + baseUrl: "https://alpha.example.com/v1", + api: "openai-responses", + apiKey: "ALPHA_KEY", + models: [{ id: "alpha-model" }], + }, + "local-provider": { + baseUrl: "http://127.0.0.1:1234/v1", + api: "openai-responses", + auth: "none", + models: [{ id: "local-model" }], + }, + }); + using _hook = hookFetch(() => { + throw new Error("active-provider resolution must not perform I/O"); + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + expect(activeRowsFor(registry, ["alpha-provider", "local-provider", "zeta.provider"])).toEqual([ + { provider: "alpha-provider", connectionKind: "credential" }, + { provider: "local-provider", connectionKind: "credentialless" }, + { provider: "zeta.provider", connectionKind: "credential" }, + ]); + }); + test("keeps bundled credentialed providers active when discovery is configured", () => { + writeRawModelsJson({ + openai: { + baseUrl: "https://openai.example.com/v1", + apiKey: "OPENAI_TEST_KEY", + api: "openai-completions", + discovery: { type: "openai-models-list" }, + models: [], + }, + }); + using _hook = hookFetch(() => { + throw new Error("active-provider resolution must not perform discovery I/O"); + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + expect(registry.getProviderDiscoveryState("openai")?.status).toBe("idle"); + expect(registry.find("openai", "gpt-4o-mini")).toBeDefined(); + expect(activeRowsFor(registry, ["openai"])).toEqual([{ provider: "openai", connectionKind: "credential" }]); + }); + test("excludes bundled providers when the selected stored key resolver returns undefined", async () => { + authStorage.close(); + authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"), { + configValueResolver: async () => undefined, + }); + await authStorage.set("anthropic", [{ type: "api_key", key: "!missing-anthropic-key" }]); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refresh(); + + expect(registry.getAll().some(model => model.provider === "anthropic")).toBe(true); + expect(activeRowsFor(registry, ["anthropic"])).toEqual([]); + }); + + test("tracks credential addition, replacement, removal, dedupe, and registry-only exclusions", async () => { + writeRawModelsJson({ + "tracked-provider": { + baseUrl: "https://tracked.example.com/v1", + api: "openai-responses", + apiKeyEnv: "GJC_TEST_MISSING_TRACKED_PROVIDER_KEY", + models: [{ id: "tracked-model" }], + }, + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const trackedRows = () => activeRowsFor(registry, ["tracked-provider"]); + + expect(registry.find("tracked-provider", "tracked-model")).toBeDefined(); + expect(trackedRows()).toEqual([]); + authStorage.setRuntimeApiKey("tracked-provider", ""); + expect(trackedRows()).toEqual([]); + + await authStorage.set("tracked-provider", [ + { type: "api_key", key: "account-a" }, + { type: "api_key", key: "account-b" }, + ]); + expect(trackedRows()).toEqual([{ provider: "tracked-provider", connectionKind: "credential" }]); + + await authStorage.set("tracked-provider", [{ type: "api_key", key: "replacement" }]); + expect(trackedRows()).toEqual([{ provider: "tracked-provider", connectionKind: "credential" }]); + + authStorage.setRuntimeApiKey("unknown-provider", "unknown-provider-key"); + expect(registry.getActiveProviders().some(provider => provider.provider === "unknown-provider")).toBe(false); + + await authStorage.set("tracked-provider", []); + expect(trackedRows()).toEqual([]); + }); + + test("does not advertise a fresh configured-discovery cache reused without a probe", async () => { + const cachedModel: Model<"openai-responses"> = { + id: "cached-model", + name: "Cached Model", + api: "openai-responses", + provider: "discovery-provider", + baseUrl: "http://127.0.0.1:1234/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }; + writeRawModelsJson({ + "discovery-provider": { + baseUrl: "http://127.0.0.1:1234/v1", + api: "openai-responses", + apiKey: "DISCOVERY_KEY", + discovery: { type: "openai-models-list" }, + }, + }); + writeModelCache("discovery-provider", Date.now(), [cachedModel], true, "", cacheDbPath); + using _hook = hookFetch(() => { + throw new Error("online-if-uncached must reuse the fresh cache"); + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + expect(registry.getProviderDiscoveryState("discovery-provider")?.status).toBe("cached"); + expect(activeRowsFor(registry, ["discovery-provider"])).toEqual([]); + await registry.refreshProvider("discovery-provider", "online-if-uncached"); + expect(registry.getProviderDiscoveryState("discovery-provider")?.status).toBe("ok"); + expect(activeRowsFor(registry, ["discovery-provider"])).toEqual([]); + }); + test("advertises credentialless cached discovery without credential evidence", () => { + const cachedModel: Model<"openai-responses"> = { + id: "cached-model", + name: "Cached Model", + api: "openai-responses", + provider: "credentialless-provider", + baseUrl: "http://127.0.0.1:1234/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }; + writeRawModelsJson({ + "credentialless-provider": { + baseUrl: "http://127.0.0.1:1234/v1", + api: "openai-responses", + auth: "none", + discovery: { type: "openai-models-list" }, + }, + }); + writeModelCache("credentialless-provider", Date.now(), [cachedModel], true, "", cacheDbPath); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + expect(registry.getProviderDiscoveryState("credentialless-provider")?.status).toBe("cached"); + expect(activeRowsFor(registry, ["credentialless-provider"])).toEqual([ + { provider: "credentialless-provider", connectionKind: "credentialless" }, + ]); + }); + test("normalizes cached LM Studio root endpoints for custom providers", () => { + const cachedModel: Model<"openai-completions"> = { + id: "cached-model", + name: "Cached Model", + api: "openai-completions", + provider: "custom-lm-studio", + baseUrl: "http://127.0.0.1:1234/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }; + writeRawModelsJson({ + "custom-lm-studio": { + baseUrl: "http://127.0.0.1:1234", + api: "openai-completions", + auth: "none", + discovery: { type: "lm-studio" }, + }, + }); + writeModelCache("custom-lm-studio", Date.now(), [cachedModel], true, "", cacheDbPath); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + expect(activeRowsFor(registry, ["custom-lm-studio"])).toEqual([ + { provider: "custom-lm-studio", connectionKind: "credentialless" }, + ]); + }); + test("advertises configured vLLM cached discovery without descriptor evidence", () => { + const cachedModel: Model<"openai-completions"> = { + id: "cached-model", + name: "Cached Model", + api: "openai-completions", + provider: "vllm", + baseUrl: "http://127.0.0.1:8000/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }; + writeRawModelsJson({ + vllm: { + baseUrl: "http://127.0.0.1:8000/v1", + api: "openai-completions", + auth: "none", + discovery: { type: "openai-models-list" }, + }, + }); + writeModelCache("vllm", Date.now(), [cachedModel], true, "", cacheDbPath); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + expect(registry.getProviderDiscoveryState("vllm")?.status).toBe("cached"); + expect(activeRowsFor(registry, ["vllm"])).toEqual([{ provider: "vllm", connectionKind: "credentialless" }]); + }); + test("does not advertise credentialless cached discovery from an obsolete endpoint", () => { + const cachedModel: Model<"openai-responses"> = { + id: "cached-model", + name: "Cached Model", + api: "openai-responses", + provider: "credentialless-provider", + baseUrl: "http://127.0.0.1:1234/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }; + writeRawModelsJson({ + "credentialless-provider": { + baseUrl: "http://127.0.0.1:5678/v1", + api: "openai-responses", + auth: "none", + discovery: { type: "openai-models-list" }, + }, + }); + writeModelCache("credentialless-provider", Date.now(), [cachedModel], true, "", cacheDbPath); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + expect(registry.getProviderDiscoveryState("credentialless-provider")?.status).toBe("cached"); + expect(activeRowsFor(registry, ["credentialless-provider"])).toEqual([]); + }); + test("does not advertise cached Ollama models without credentialless discovery provenance", () => { + const restoreBaseUrl = setEnvForTest("OLLAMA_BASE_URL", "http://127.0.0.1:11434"); + const restoreApiKey = unsetEnvForTest("OLLAMA_API_KEY"); + try { + const cachedModel: Model<"openai-completions"> = { + id: "cached-ollama-model", + name: "Cached Ollama Model", + api: "openai-completions", + provider: "ollama", + baseUrl: "http://127.0.0.1:11434/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }; + writeModelCache("ollama", Date.now(), [cachedModel], true, "", cacheDbPath); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + expect(registry.getProviderDiscoveryState("ollama")?.status).toBe("cached"); + expect(activeRowsFor(registry, ["ollama"])).toEqual([]); + } finally { + restoreApiKey(); + restoreBaseUrl(); + } + }); + test("does not advertise cached LM Studio models without credentialless discovery provenance", () => { + const restoreBaseUrl = setEnvForTest("LM_STUDIO_BASE_URL", "http://127.0.0.1:1234"); + const restoreApiKey = unsetEnvForTest("LM_STUDIO_API_KEY"); + try { + const cachedModel: Model<"openai-completions"> = { + id: "cached-lm-studio-model", + name: "Cached LM Studio Model", + api: "openai-completions", + provider: "lm-studio", + baseUrl: "http://127.0.0.1:1234/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }; + writeModelCache("lm-studio", Date.now(), [cachedModel], true, "", cacheDbPath); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + expect(registry.getProviderDiscoveryState("lm-studio")?.status).toBe("cached"); + expect(activeRowsFor(registry, ["lm-studio"])).toEqual([]); + } finally { + restoreApiKey(); + restoreBaseUrl(); + } + }); + test("keeps signed LM Studio endpoint queries out of the model cache", async () => { + const restoreBaseUrl = setEnvForTest("LM_STUDIO_BASE_URL", "https://lm-studio.example?sig=lm-studio-secret"); + const restoreApiKey = unsetEnvForTest("LM_STUDIO_API_KEY"); + try { + using _hook = hookFetch(input => { + expect(String(input)).toBe("https://lm-studio.example/v1/models?sig=lm-studio-secret"); + return new Response(JSON.stringify({ data: [{ id: "lm-studio-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + await registry.refreshProvider("lm-studio", "online"); + + expect(registry.find("lm-studio", "lm-studio-model")?.baseUrl).toBe( + "https://lm-studio.example?sig=lm-studio-secret", + ); + const cached = readModelCache("lm-studio", 24 * 60 * 60 * 1000, Date.now, cacheDbPath); + expect(cached?.models[0]?.baseUrl).toBe("https://lm-studio.example/v1"); + expect(JSON.stringify(cached)).not.toContain("lm-studio-secret"); + } finally { + restoreApiKey(); + restoreBaseUrl(); + } + }); + test("records configured discovery evidence after resolving a stored command key", async () => { + authStorage.close(); + authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"), { + configValueResolver: async config => (config === "!discovery-key" ? "resolved-discovery-key" : undefined), + }); + writeRawModelsJson({ + "discovery-provider": { + baseUrl: "https://discovery.example.com/v1", + api: "openai-responses", + discovery: { type: "openai-models-list" }, + }, + }); + await authStorage.set("discovery-provider", [{ type: "api_key", key: "!discovery-key" }]); + using _hook = hookFetch( + () => + new Response(JSON.stringify({ data: [{ id: "discovered-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + await registry.refreshProvider("discovery-provider", "online"); + + expect(activeRowsFor(registry, ["discovery-provider"])).toEqual([ + { provider: "discovery-provider", connectionKind: "credential" }, + ]); + await registry.refreshProvider("discovery-provider", "online-if-uncached"); + + expect(registry.getProviderDiscoveryState("discovery-provider")?.status).toBe("ok"); + expect(activeRowsFor(registry, ["discovery-provider"])).toEqual([ + { provider: "discovery-provider", connectionKind: "credential" }, + ]); + }); + test("forces an online configured discovery probe after the credential changes", async () => { + writeRawModelsJson({ + "discovery-provider": { + baseUrl: "https://discovery.example.com/v1", + api: "openai-responses", + discovery: { type: "openai-models-list" }, + }, + }); + authStorage.setRuntimeApiKey("discovery-provider", "credential-a"); + let requests = 0; + using _hook = hookFetch(() => { + requests++; + return new Response(JSON.stringify({ data: [{ id: `discovered-model-${requests}` }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + await registry.refreshProvider("discovery-provider", "online"); + authStorage.setRuntimeApiKey("discovery-provider", "credential-b"); + await registry.refreshProvider("discovery-provider", "online-if-uncached"); + + expect(requests).toBe(2); + expect(registry.find("discovery-provider", "discovered-model-2")).toBeDefined(); + expect(activeRowsFor(registry, ["discovery-provider"])).toEqual([ + { provider: "discovery-provider", connectionKind: "credential" }, + ]); + }); + test("refreshes configured discovery when round-robin credentials change", async () => { + writeRawModelsJson({ + "discovery-provider": { + baseUrl: "https://discovery.example.com/v1", + api: "openai-responses", + discovery: { type: "openai-models-list" }, + }, + }); + await authStorage.set("discovery-provider", [ + { type: "api_key", key: "credential-a" }, + { type: "api_key", key: "credential-b" }, + ]); + const requestKeys: string[] = []; + using _hook = hookFetch((_input, init) => { + const key = (init?.headers as Record).Authorization; + requestKeys.push(key); + return new Response(JSON.stringify({ data: [{ id: `discovered-model-${requestKeys.length}` }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + await registry.refreshProvider("discovery-provider", "online"); + await registry.refreshProvider("discovery-provider", "online-if-uncached"); + + expect(requestKeys).toEqual(["Bearer credential-a", "Bearer credential-b"]); + expect(registry.find("discovery-provider", "discovered-model-2")).toBeDefined(); + }); + test("keeps selected discovery evidence local to each registry", async () => { + writeRawModelsJson({ + "discovery-provider": { + baseUrl: "https://discovery.example.com/v1", + api: "openai-responses", + discovery: { type: "openai-models-list" }, + }, + }); + await authStorage.set("discovery-provider", [ + { type: "api_key", key: "credential-a" }, + { type: "api_key", key: "credential-b" }, + ]); + const requestKeys: string[] = []; + using _hook = hookFetch((_input, init) => { + requestKeys.push((init?.headers as Record).Authorization); + return new Response(JSON.stringify({ data: [{ id: "discovered-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + const firstRegistry = new ModelRegistry(authStorage, modelsJsonPath); + await firstRegistry.refreshProvider("discovery-provider", "online"); + + const secondRegistry = new ModelRegistry(authStorage, modelsJsonPath); + await secondRegistry.refreshProvider("discovery-provider", "online"); + + expect(requestKeys).toEqual(["Bearer credential-a", "Bearer credential-b"]); + expect(activeRowsFor(firstRegistry, ["discovery-provider"])).toEqual([ + { provider: "discovery-provider", connectionKind: "credential" }, + ]); + expect(activeRowsFor(secondRegistry, ["discovery-provider"])).toEqual([ + { provider: "discovery-provider", connectionKind: "credential" }, + ]); + }); + test("does not publish a command-backed discovery after its credentials are replaced during preflight", async () => { + writeRawModelsJson({ + "discovery-provider": { + baseUrl: "https://discovery.example.com/v1", + api: "openai-responses", + discovery: { type: "openai-models-list" }, + }, + }); + authStorage.close(); + const firstKeyResolution = Promise.withResolvers(); + const firstKeyRequested = Promise.withResolvers(); + authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"), { + configValueResolver: async config => { + if (config === "!credential-a") { + firstKeyRequested.resolve(); + return firstKeyResolution.promise; + } + return config === "!credential-b" ? "credential-b" : undefined; + }, + }); + await authStorage.set("discovery-provider", [{ type: "api_key", key: "!credential-a" }]); + const requestKeys: string[] = []; + using _hook = hookFetch((_input, init) => { + requestKeys.push((init?.headers as Record).Authorization); + return new Response(JSON.stringify({ data: [{ id: "discovered-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + const staleRefresh = registry.refreshProvider("discovery-provider", "online"); + await firstKeyRequested.promise; + await authStorage.set("discovery-provider", [{ type: "api_key", key: "!credential-b" }]); + firstKeyResolution.resolve("credential-a"); + await staleRefresh; + + expect(requestKeys).toEqual([]); + expect(registry.find("discovery-provider", "discovered-model")).toBeUndefined(); + + await registry.refreshProvider("discovery-provider", "online"); + + expect(requestKeys).toEqual(["Bearer credential-b"]); + expect(activeRowsFor(registry, ["discovery-provider"])).toEqual([ + { provider: "discovery-provider", connectionKind: "credential" }, + ]); + }); + test("does not retain configured discovery evidence after an in-flight credential change", async () => { + writeRawModelsJson({ + "discovery-provider": { + baseUrl: "https://discovery.example.com/v1", + api: "openai-responses", + discovery: { type: "openai-models-list" }, + }, + }); + authStorage.setRuntimeApiKey("discovery-provider", "credential-a"); + const { promise: response, resolve: resolveResponse } = Promise.withResolvers(); + using _hook = hookFetch(() => response); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + const refresh = registry.refreshProvider("discovery-provider", "online"); + await Bun.sleep(0); + authStorage.setRuntimeApiKey("discovery-provider", "credential-b"); + resolveResponse( + new Response(JSON.stringify({ data: [{ id: "discovered-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + await refresh; + + expect(activeRowsFor(registry, ["discovery-provider"])).toEqual([]); + expect(registry.find("discovery-provider", "discovered-model")).toBeUndefined(); + expect(registry.getProviderDiscoveryState("discovery-provider")?.status).toBe("idle"); + }); + test("discards a completed configured discovery after another provider delays the aggregate refresh", async () => { + writeRawModelsJson({ + "first-discovery-provider": { + baseUrl: "https://first-discovery.example.com/v1", + api: "openai-responses", + discovery: { type: "openai-models-list" }, + }, + "second-discovery-provider": { + baseUrl: "https://second-discovery.example.com/v1", + api: "openai-responses", + discovery: { type: "openai-models-list" }, + }, + }); + authStorage.setRuntimeApiKey("first-discovery-provider", "credential-a"); + authStorage.setRuntimeApiKey("second-discovery-provider", "credential-b"); + const { promise: secondResponse, resolve: resolveSecondResponse } = Promise.withResolvers(); + using _hook = hookFetch(input => { + switch (String(input)) { + case "https://first-discovery.example.com/v1/models": + return new Response(JSON.stringify({ data: [{ id: "first-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + case "https://second-discovery.example.com/v1/models": + return secondResponse; + default: + throw new Error(`Unexpected URL: ${input}`); + } + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + const refresh = registry.refresh(); + await Bun.sleep(0); + authStorage.setRuntimeApiKey("first-discovery-provider", "credential-a-rotated"); + resolveSecondResponse( + new Response(JSON.stringify({ data: [{ id: "second-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + await refresh; + + expect(registry.find("first-discovery-provider", "first-model")).toBeUndefined(); + expect(registry.find("second-discovery-provider", "second-model")).toBeDefined(); + expect(activeRowsFor(registry, ["first-discovery-provider"])).toEqual([]); + expect(registry.getProviderDiscoveryState("first-discovery-provider")).toBeUndefined(); + }); + test("invalidates a completed discovery state after an aggregate environment credential change", async () => { + const restoreFirstKey = setEnvForTest("GJC_TEST_FIRST_DISCOVERY_KEY", "credential-a"); + try { + writeRawModelsJson({ + "first-discovery-provider": { + baseUrl: "https://first-discovery.example.com/v1", + api: "openai-responses", + discovery: { type: "openai-models-list" }, + }, + "second-discovery-provider": { + baseUrl: "https://second-discovery.example.com/v1", + api: "openai-responses", + discovery: { type: "openai-models-list" }, + }, + }); + await authStorage.set("first-discovery-provider", [ + { type: "api_key", key: "GJC_TEST_FIRST_DISCOVERY_KEY" }, + ]); + authStorage.setRuntimeApiKey("second-discovery-provider", "credential-b"); + const { promise: secondResponse, resolve: resolveSecondResponse } = Promise.withResolvers(); + using _hook = hookFetch(input => { + switch (String(input)) { + case "https://first-discovery.example.com/v1/models": + return new Response(JSON.stringify({ data: [{ id: "first-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + case "https://second-discovery.example.com/v1/models": + return secondResponse; + default: + throw new Error(`Unexpected URL: ${input}`); + } + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + const refresh = registry.refresh(); + await Bun.sleep(0); + process.env.GJC_TEST_FIRST_DISCOVERY_KEY = "credential-a-rotated"; + resolveSecondResponse( + new Response(JSON.stringify({ data: [{ id: "second-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + await refresh; + + expect(registry.find("first-discovery-provider", "first-model")).toBeUndefined(); + expect(registry.getProviderDiscoveryState("first-discovery-provider")).toBeUndefined(); + } finally { + restoreFirstKey(); + } + }); + test("does not retain configured discovery evidence after an in-flight endpoint change", async () => { + writeRawModelsJson({ + "discovery-provider": { + api: "openai-responses", + apiKey: "DISCOVERY_KEY", + discovery: { type: "openai-models-list" }, + }, + }); + const restore = setEnvForTest("DISCOVERY_PROVIDER_BASE_URL", "https://tenant-a.example.com/v1"); + try { + const { promise: response, resolve: resolveResponse } = Promise.withResolvers(); + using _hook = hookFetch(() => response); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + const refresh = registry.refreshProvider("discovery-provider", "online"); + await Bun.sleep(0); + Bun.env.DISCOVERY_PROVIDER_BASE_URL = "https://tenant-b.example.com/v1"; + resolveResponse( + new Response(JSON.stringify({ data: [{ id: "discovered-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + await refresh; + + expect(activeRowsFor(registry, ["discovery-provider"])).toEqual([]); + expect(registry.find("discovery-provider", "discovered-model")).toBeUndefined(); + expect(readModelCache("discovery-provider", 24 * 60 * 60 * 1000, Date.now, cacheDbPath)).toBeNull(); + } finally { + restore(); + } + }); + test("does not let a stale configured refresh clear newer credential evidence", async () => { + writeRawModelsJson({ + "discovery-provider": { + baseUrl: "https://discovery.example.com/v1", + api: "openai-responses", + discovery: { type: "openai-models-list" }, + }, + }); + authStorage.setRuntimeApiKey("discovery-provider", "credential-a"); + const { promise: olderResponse, resolve: resolveOlder } = Promise.withResolvers(); + const { promise: newerResponse, resolve: resolveNewer } = Promise.withResolvers(); + let calls = 0; + using _hook = hookFetch(() => (calls++ === 0 ? olderResponse : newerResponse)); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + const olderRefresh = registry.refreshProvider("discovery-provider", "online"); + await Bun.sleep(0); + authStorage.setRuntimeApiKey("discovery-provider", "credential-b"); + const newerRefresh = registry.refreshProvider("discovery-provider", "online"); + await Bun.sleep(0); + resolveNewer( + new Response(JSON.stringify({ data: [{ id: "discovered-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + await newerRefresh; + resolveOlder( + new Response(JSON.stringify({ data: [{ id: "discovered-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + await olderRefresh; + + expect(activeRowsFor(registry, ["discovery-provider"])).toEqual([ + { provider: "discovery-provider", connectionKind: "credential" }, + ]); + }); + test("retains configured discovery proof across an offline cache refresh", async () => { + writeRawModelsJson({ + "discovery-provider": { + baseUrl: "https://discovery.example.com/v1", + api: "openai-responses", + apiKey: "DISCOVERY_KEY", + discovery: { type: "openai-models-list" }, + }, + }); + using _hook = hookFetch( + () => + new Response(JSON.stringify({ data: [{ id: "discovered-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + await registry.refreshProvider("discovery-provider", "online"); + await registry.refreshProvider("discovery-provider", "offline"); + + expect(registry.getProviderDiscoveryState("discovery-provider")?.status).toBe("cached"); + expect(activeRowsFor(registry, ["discovery-provider"])).toEqual([ + { provider: "discovery-provider", connectionKind: "credential" }, + ]); + }); + test("invalidates configured discovery proof when its environment endpoint changes", async () => { + writeRawModelsJson({ + "discovery-provider": { + api: "openai-responses", + apiKey: "DISCOVERY_KEY", + discovery: { type: "openai-models-list" }, + }, + }); + const restore = setEnvForTest("DISCOVERY_PROVIDER_BASE_URL", "https://tenant-a.example.com/v1"); + try { + using _hook = hookFetch(input => { + expect(String(input)).toBe("https://tenant-a.example.com/v1/models"); + return new Response(JSON.stringify({ data: [{ id: "discovered-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + await registry.refreshProvider("discovery-provider", "online"); + Bun.env.DISCOVERY_PROVIDER_BASE_URL = "https://tenant-b.example.com/v1"; + await registry.refreshProvider("discovery-provider", "offline"); + + expect(activeRowsFor(registry, ["discovery-provider"])).toEqual([]); + } finally { + restore(); + } + }); + test("re-resolves an environment endpoint before an online configured discovery", async () => { + writeRawModelsJson({ + "discovery-provider": { + api: "openai-responses", + apiKey: "DISCOVERY_KEY", + discovery: { type: "openai-models-list" }, + }, + }); + const restore = setEnvForTest("DISCOVERY_PROVIDER_BASE_URL", "https://tenant-a.example.com/v1"); + try { + const requestedUrls: string[] = []; + using _hook = hookFetch(input => { + requestedUrls.push(String(input)); + return new Response(JSON.stringify({ data: [{ id: "discovered-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + await registry.refreshProvider("discovery-provider", "online"); + Bun.env.DISCOVERY_PROVIDER_BASE_URL = "https://tenant-b.example.com/v1"; + await registry.refreshProvider("discovery-provider", "online"); + + expect(requestedUrls).toEqual([ + "https://tenant-a.example.com/v1/models", + "https://tenant-b.example.com/v1/models", + ]); + } finally { + restore(); + } + }); + test("clears configured discovery proof after a failed online probe", async () => { + writeRawModelsJson({ + "discovery-provider": { + baseUrl: "https://discovery.example.com/v1", + api: "openai-responses", + apiKey: "DISCOVERY_KEY", + discovery: { type: "openai-models-list" }, + }, + }); + let available = true; + using _hook = hookFetch(() => + available + ? new Response(JSON.stringify({ data: [{ id: "discovered-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + : new Response("unavailable", { status: 503 }), + ); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + await registry.refreshProvider("discovery-provider", "online"); + available = false; + await registry.refreshProvider("discovery-provider", "online"); + + expect(registry.getProviderDiscoveryState("discovery-provider")?.status).toBe("cached"); + expect(activeRowsFor(registry, ["discovery-provider"])).toEqual([]); + }); + test("uses the runtime endpoint query for configured discovery and completion", async () => { + writeRawModelsJson({ + "discovery-provider": { + baseUrl: "https://configured.example.com/v1", + api: "openai-completions", + apiKey: "DISCOVERY_KEY", + discovery: { type: "openai-models-list" }, + }, + }); + const discoveryUrl = "https://runtime.example.com/v1/models?sig=runtime-secret"; + const completionUrl = "https://runtime.example.com/v1/chat/completions?sig=runtime-secret"; + const requestedUrls: string[] = []; + using _hook = hookFetch(input => { + const url = String(input); + requestedUrls.push(url); + if (url === discoveryUrl) { + return new Response(JSON.stringify({ data: [{ id: "runtime-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === completionUrl) { + const body = [ + `data: ${JSON.stringify({ + id: "chatcmpl-query", + object: "chat.completion.chunk", + created: 0, + model: "runtime-model", + choices: [{ index: 0, delta: { role: "assistant", content: "ok" }, finish_reason: null }], + })}`, + `data: ${JSON.stringify({ + id: "chatcmpl-query", + object: "chat.completion.chunk", + created: 0, + model: "runtime-model", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + })}`, + "data: [DONE]", + "", + ].join("\n\n"); + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + registry.registerProvider("discovery-provider", { + baseUrl: "https://runtime.example.com/v1?sig=runtime-secret", + }); + + await registry.refreshProvider("discovery-provider", "online"); + + const model = registry.find("discovery-provider", "runtime-model"); + expect(model?.baseUrl).toBe("https://runtime.example.com/v1?sig=runtime-secret"); + const cached = readModelCache("discovery-provider", 24 * 60 * 60 * 1000, Date.now, cacheDbPath); + expect(cached?.models).toHaveLength(1); + expect(cached?.models[0]?.baseUrl).toBe("https://runtime.example.com/v1"); + expect(JSON.stringify(cached)).not.toContain("runtime-secret"); + expect(activeRowsFor(registry, ["discovery-provider"])).toEqual([ + { provider: "discovery-provider", connectionKind: "credential" }, + ]); + + const result = await streamOpenAICompletions( + model as Model<"openai-completions">, + { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + } satisfies Context, + { apiKey: "DISCOVERY_KEY" }, + ).result(); + expect(result.stopReason).toBe("stop"); + expect(requestedUrls).toEqual([discoveryUrl, completionUrl]); + const cachedRegistry = new ModelRegistry(authStorage, modelsJsonPath); + cachedRegistry.registerProvider("discovery-provider", { + baseUrl: "https://runtime.example.com/v1?sig=runtime-secret", + }); + expect(cachedRegistry.find("discovery-provider", "runtime-model")?.baseUrl).toBe( + "https://runtime.example.com/v1?sig=runtime-secret", + ); + }); + test("does not restore configured discovery evidence after a transport override", async () => { + writeRawModelsJson({ + "discovery-provider": { + baseUrl: "https://discovery.example.com/v1", + api: "openai-responses", + apiKey: "DISCOVERY_KEY", + discovery: { type: "openai-models-list" }, + }, + }); + const { promise: response, resolve: resolveResponse } = Promise.withResolvers(); + using _hook = hookFetch(() => response); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + const refresh = registry.refreshProvider("discovery-provider", "online"); + await Bun.sleep(0); + registry.registerProvider("discovery-provider", { baseUrl: "https://override.example.com/v1" }); + resolveResponse( + new Response(JSON.stringify({ data: [{ id: "discovered-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + await refresh; + + expect(activeRowsFor(registry, ["discovery-provider"])).toEqual([]); + }); + test("does not advertise authenticated descriptor-only cached models without activity evidence", () => { + const cachedModel: Model<"openai-completions"> = { + id: "cached-vllm-model", + name: "Cached vLLM Model", + api: "openai-completions", + provider: "vllm", + baseUrl: "http://127.0.0.1:8000/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }; + writeModelCache("vllm", Date.now(), [cachedModel], true, "", cacheDbPath); + authStorage.setRuntimeApiKey("vllm", "cached-vllm-key"); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + expect(registry.find("vllm", "cached-vllm-model")).toBeDefined(); + expect(activeRowsFor(registry, ["vllm"])).toEqual([]); + }); + test("does not treat descriptor overrides as configured static models", () => { + const cachedModel: Model<"openai-completions"> = { + id: "cached-vllm-model", + name: "Cached vLLM Model", + api: "openai-completions", + provider: "vllm", + baseUrl: "http://127.0.0.1:8000/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }; + writeRawModelsJson({ + vllm: { baseUrl: "http://127.0.0.1:8000/v1", apiKey: "configured-vllm-key" }, + }); + writeModelCache("vllm", Date.now(), [cachedModel], true, "", cacheDbPath); + authStorage.setRuntimeApiKey("vllm", "cached-vllm-key"); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + expect(registry.find("vllm", "cached-vllm-model")).toBeDefined(); + expect(activeRowsFor(registry, ["vllm"])).toEqual([]); + }); + test("does not advertise descriptor-only providers from a fresh cache reused offline", async () => { + const cachedModel: Model<"openai-completions"> = { + id: "cached-vllm-model", + name: "Cached vLLM Model", + api: "openai-completions", + provider: "vllm", + baseUrl: "http://127.0.0.1:8000/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }; + writeModelCache("vllm", Date.now(), [cachedModel], true, "", cacheDbPath); + authStorage.setRuntimeApiKey("vllm", "cached-vllm-key"); + using _hook = hookFetch(() => { + throw new Error("online-if-uncached must reuse the fresh cache"); + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("vllm", "online-if-uncached"); + + expect(activeRowsFor(registry, ["vllm"])).toEqual([]); + }); + test("discovers OpenCodex as credentialless when the local proxy is healthy", async () => { + const restoreOpenCodexHome = setEnvForTest("OPENCODEX_HOME", tempDir); + await Bun.write( + path.join(tempDir, "runtime-port.json"), + JSON.stringify({ hostname: "127.0.0.1", port: 10201 }), + ); + using _hook = hookFetch(input => { + const url = String(input); + if (url === "http://127.0.0.1:10201/healthz") { + return new Response(JSON.stringify({ ok: true, version: "opencodex", port: 10201 }), { status: 200 }); + } + if (url === "http://127.0.0.1:10201/api/models") { + return new Response(JSON.stringify([{ id: "provider/model", name: "Provider Model" }]), { status: 200 }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + try { + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("opencodex", "online"); + + expect(registry.find("opencodex", "opencodex/provider/model")).toBeDefined(); + expect(registry.getAvailable().map(model => `${model.provider}/${model.id}`)).toContain( + "opencodex/opencodex/provider/model", + ); + expect(activeRowsFor(registry, ["opencodex"])).toEqual([ + { provider: "opencodex", connectionKind: "credentialless" }, + ]); + } finally { + restoreOpenCodexHome(); + } + }); + test("discovers OpenCodex as credentialless after a command credential resolves empty", async () => { + authStorage.close(); + authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"), { + configValueResolver: async () => undefined, + }); + await authStorage.set("opencodex", [{ type: "api_key", key: "!missing-opencodex-key" }]); + const restoreOpenCodexHome = setEnvForTest("OPENCODEX_HOME", tempDir); + await Bun.write( + path.join(tempDir, "runtime-port.json"), + JSON.stringify({ hostname: "127.0.0.1", port: 10201 }), + ); + using _hook = hookFetch(input => { + const url = String(input); + if (url === "http://127.0.0.1:10201/healthz") { + return new Response(JSON.stringify({ ok: true, version: "opencodex", port: 10201 }), { status: 200 }); + } + if (url === "http://127.0.0.1:10201/api/models") { + return new Response(JSON.stringify([{ id: "provider/model", name: "Provider Model" }]), { status: 200 }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + try { + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("opencodex", "online"); + + expect(activeRowsFor(registry, ["opencodex"])).toEqual([ + { provider: "opencodex", connectionKind: "credentialless" }, + ]); + await expect(registry.getApiKeyForProvider("opencodex")).resolves.toBe(kNoAuth); + } finally { + restoreOpenCodexHome(); + } + }); + test("advertises descriptor-only providers after a fresh online-if-uncached discovery", async () => { + authStorage.setRuntimeApiKey("vllm", "fresh-vllm-key"); + using _hook = hookFetch(input => { + expect(String(input)).toBe("http://127.0.0.1:8000/v1/models"); + return new Response(JSON.stringify({ data: [{ id: "fresh-vllm-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("vllm", "online-if-uncached"); + + expect(registry.find("vllm", "fresh-vllm-model")).toBeDefined(); + expect(activeRowsFor(registry, ["vllm"])).toEqual([{ provider: "vllm", connectionKind: "credential" }]); + }); + test("discovers Xiaomi token-plan models at their credential-derived endpoint", async () => { + authStorage.setRuntimeApiKey("xiaomi", "tp-sgp-token"); + using _hook = hookFetch(input => { + expect(String(input)).toBe("https://token-plan-sgp.xiaomimimo.com/v1/models"); + return new Response(JSON.stringify({ data: [{ id: "token-plan-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + await registry.refreshProvider("xiaomi", "online"); + + expect(registry.find("xiaomi", "token-plan-model")?.baseUrl).toBe("https://token-plan-sgp.xiaomimimo.com/v1"); + expect(activeRowsFor(registry, ["xiaomi"])).toEqual([{ provider: "xiaomi", connectionKind: "credential" }]); + }); + test("keeps signed descriptor endpoints out of the model cache", async () => { + const restoreBaseUrl = setEnvForTest("VLLM_BASE_URL", "https://vllm.example.com/v1?sig=descriptor-secret"); + authStorage.setRuntimeApiKey("vllm", "fresh-vllm-key"); + using _hook = hookFetch(input => { + expect(String(input)).toBe("https://vllm.example.com/v1/models?sig=descriptor-secret"); + return new Response(JSON.stringify({ data: [{ id: "signed-vllm-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + try { + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("vllm", "online"); + + expect(registry.find("vllm", "signed-vllm-model")?.baseUrl).toBe( + "https://vllm.example.com/v1?sig=descriptor-secret", + ); + const cached = readModelCache("vllm", 24 * 60 * 60 * 1000, Date.now, cacheDbPath); + expect(cached?.models[0]?.baseUrl).toBe("https://vllm.example.com/v1"); + expect(JSON.stringify(cached)).not.toContain("descriptor-secret"); + } finally { + restoreBaseUrl(); + } + }); + test("keeps signed models.dev descriptor rows out of the model cache", async () => { + const restoreBaseUrl = setEnvForTest( + "ANTHROPIC_BASE_URL", + "https://anthropic.example.com/v1?sig=models-dev-secret", + ); + authStorage.setRuntimeApiKey("anthropic", "fresh-anthropic-key"); + using _hook = hookFetch(input => { + const url = String(input); + if (url === "https://models.dev/api.json") { + return new Response( + JSON.stringify({ + anthropic: { + models: { + "models-dev-only": { + name: "Models.dev Only", + tool_call: true, + modalities: { input: ["text"] }, + cost: { input: 1, output: 1 }, + limit: { context: 128000, output: 8192 }, + }, + }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "https://anthropic.example.com/v1/models?sig=models-dev-secret") { + return new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + try { + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("anthropic", "online"); + + const cached = readModelCache("anthropic", 24 * 60 * 60 * 1000, Date.now, cacheDbPath); + expect(cached?.models.find(model => model.id === "models-dev-only")?.baseUrl).toBe( + "https://anthropic.example.com/v1", + ); + expect(JSON.stringify(cached)).not.toContain("models-dev-secret"); + } finally { + restoreBaseUrl(); + } + }); + test("discovers descriptor-only providers on the first refresh with a stored API key", async () => { + await authStorage.set("vllm", [{ type: "api_key", key: "stored-vllm-key" }]); + using _hook = hookFetch(input => { + expect(String(input)).toBe("http://127.0.0.1:8000/v1/models"); + return new Response(JSON.stringify({ data: [{ id: "stored-vllm-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("vllm", "online-if-uncached"); + + expect(registry.find("vllm", "stored-vllm-model")).toBeDefined(); + expect(activeRowsFor(registry, ["vllm"])).toEqual([{ provider: "vllm", connectionKind: "credential" }]); + }); + test("does not publish descriptor discovery after its command credential is replaced during preflight", async () => { + authStorage.close(); + const firstKeyResolution = Promise.withResolvers(); + const firstKeyRequested = Promise.withResolvers(); + authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"), { + configValueResolver: async config => { + if (config === "!vllm-key-a") { + firstKeyRequested.resolve(); + return firstKeyResolution.promise; + } + return config === "!vllm-key-b" ? "vllm-key-b" : undefined; + }, + }); + await authStorage.set("vllm", [{ type: "api_key", key: "!vllm-key-a" }]); + const requestApiKeys: string[] = []; + using _hook = hookFetch((_input, init) => { + requestApiKeys.push(new Headers(init?.headers).get("Authorization") ?? ""); + return new Response(JSON.stringify({ data: [{ id: "command-vllm-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + const staleRefresh = registry.refreshProvider("vllm", "online"); + await firstKeyRequested.promise; + await authStorage.set("vllm", [{ type: "api_key", key: "!vllm-key-b" }]); + firstKeyResolution.resolve("vllm-key-a"); + await staleRefresh; + + expect(requestApiKeys).toEqual([]); + expect(registry.find("vllm", "command-vllm-model")).toBeUndefined(); + expect(readModelCache("vllm", 24 * 60 * 60 * 1000, Date.now, cacheDbPath)).toBeNull(); + + await registry.refreshProvider("vllm", "online"); + + expect(requestApiKeys).toEqual(["Bearer vllm-key-b"]); + expect(activeRowsFor(registry, ["vllm"])).toEqual([{ provider: "vllm", connectionKind: "credential" }]); + }); + test("discovers descriptor-only providers with the first stored command-backed key", async () => { + authStorage.close(); + authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"), { + configValueResolver: async config => { + if (config === "!vllm-key-a") return "vllm-key-a"; + if (config === "!vllm-key-b") return "vllm-key-b"; + return undefined; + }, + }); + await authStorage.set("vllm", [ + { type: "api_key", key: "!vllm-key-a" }, + { type: "api_key", key: "!vllm-key-b" }, + ]); + + const requestApiKeys: string[] = []; + using _hook = hookFetch((input, init) => { + expect(String(input)).toBe("http://127.0.0.1:8000/v1/models"); + requestApiKeys.push(new Headers(init?.headers).get("Authorization") ?? ""); + return new Response(JSON.stringify({ data: [{ id: "command-vllm-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("vllm", "online-if-uncached"); + + expect(requestApiKeys).toEqual(["Bearer vllm-key-a"]); + expect(registry.find("vllm", "command-vllm-model")).toBeDefined(); + expect(activeRowsFor(registry, ["vllm"])).toEqual([{ provider: "vllm", connectionKind: "credential" }]); + }); + test("preserves descriptor discovery evidence across an offline refresh", async () => { + authStorage.setRuntimeApiKey("vllm", "fresh-vllm-key"); + using _hook = hookFetch(input => { + expect(String(input)).toBe("http://127.0.0.1:8000/v1/models"); + return new Response(JSON.stringify({ data: [{ id: "fresh-vllm-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("vllm", "online"); + await registry.refreshProvider("vllm", "offline"); + + expect(activeRowsFor(registry, ["vllm"])).toEqual([{ provider: "vllm", connectionKind: "credential" }]); + }); + test("preserves descriptor discovery evidence with a normalized endpoint across an offline refresh", async () => { + const restore = setEnvForTest("VLLM_BASE_URL", "https://gateway.example/v1/"); + try { + authStorage.setRuntimeApiKey("vllm", "fresh-vllm-key"); + using _hook = hookFetch(input => { + expect(String(input)).toBe("https://gateway.example/v1/models"); + return new Response(JSON.stringify({ data: [{ id: "fresh-vllm-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + await registry.refreshProvider("vllm", "online"); + await registry.refreshProvider("vllm", "offline"); + + expect(activeRowsFor(registry, ["vllm"])).toEqual([{ provider: "vllm", connectionKind: "credential" }]); + } finally { + restore(); + } + }); + test("forces an online descriptor probe when its endpoint query changes", async () => { + const restore = setEnvForTest("VLLM_BASE_URL", "https://gateway.example/v1?tenant=a/&scope=one&scope=two"); + try { + authStorage.setRuntimeApiKey("vllm", "fresh-vllm-key"); + const requestedUrls: string[] = []; + using _hook = hookFetch(input => { + requestedUrls.push(String(input)); + return new Response(JSON.stringify({ data: [{ id: "fresh-vllm-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + await registry.refreshProvider("vllm", "online"); + Bun.env.VLLM_BASE_URL = "https://gateway.example/v1?tenant=b/&scope=one&scope=two"; + await registry.refreshProvider("vllm", "online-if-uncached"); + + expect(requestedUrls).toEqual([ + "https://gateway.example/v1/models?tenant=a/&scope=one&scope=two", + "https://gateway.example/v1/models?tenant=b/&scope=one&scope=two", + ]); + expect(registry.find("vllm", "fresh-vllm-model")?.baseUrl).toBe( + "https://gateway.example/v1?tenant=b/&scope=one&scope=two", + ); + expect(activeRowsFor(registry, ["vllm"])).toEqual([{ provider: "vllm", connectionKind: "credential" }]); + } finally { + restore(); + } + }); + test("discards an in-flight descriptor discovery after its endpoint changes", async () => { + const restore = setEnvForTest("VLLM_BASE_URL", "https://tenant-a.example.com/v1"); + try { + authStorage.setRuntimeApiKey("vllm", "fresh-vllm-key"); + const { promise: response, resolve: resolveResponse } = Promise.withResolvers(); + using _hook = hookFetch(() => response); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + const refresh = registry.refreshProvider("vllm", "online"); + await Bun.sleep(0); + Bun.env.VLLM_BASE_URL = "https://tenant-b.example.com/v1"; + resolveResponse( + new Response(JSON.stringify({ data: [{ id: "tenant-a-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + await refresh; + + expect(registry.find("vllm", "tenant-a-model")).toBeUndefined(); + expect(activeRowsFor(registry, ["vllm"])).toEqual([]); + expect(readModelCache("vllm", 24 * 60 * 60 * 1000, Date.now, cacheDbPath)).toBeNull(); + } finally { + restore(); + } + }); + test("clears descriptor discovery evidence after a failed conditional online probe", async () => { + authStorage.setRuntimeApiKey("vllm", "fresh-vllm-key"); + let calls = 0; + using _hook = hookFetch(() => + calls++ === 0 + ? new Response(JSON.stringify({ data: [{ id: "fresh-vllm-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + : new Response("unavailable", { status: 503 }), + ); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("vllm", "online"); + expect(activeRowsFor(registry, ["vllm"])).toEqual([{ provider: "vllm", connectionKind: "credential" }]); + await registry.refreshProvider("vllm", "online-if-uncached"); + expect(activeRowsFor(registry, ["vllm"])).toEqual([{ provider: "vllm", connectionKind: "credential" }]); + + writeModelCache("vllm", Date.now() - 5 * 60 * 1000, [], false, "", cacheDbPath); + await registry.refreshProvider("vllm", "online-if-uncached"); + + expect(activeRowsFor(registry, ["vllm"])).toEqual([]); + }); + test("invalidates descriptor discovery evidence when the credential changes", async () => { + authStorage.setRuntimeApiKey("vllm", "credential-a"); + using _hook = hookFetch( + () => + new Response(JSON.stringify({ data: [{ id: "fresh-vllm-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("vllm", "online"); + authStorage.setRuntimeApiKey("vllm", "credential-b"); + + expect(activeRowsFor(registry, ["vllm"])).toEqual([]); + }); + test("invalidates descriptor discovery evidence after a transport override", async () => { + authStorage.setRuntimeApiKey("vllm", "fresh-vllm-key"); + using _hook = hookFetch(input => { + expect(String(input)).toBe("http://127.0.0.1:8000/v1/models"); + return new Response(JSON.stringify({ data: [{ id: "fresh-vllm-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("vllm", "online"); + registry.registerProvider("vllm", { baseUrl: "http://127.0.0.1:9000/v1" }); + + expect(activeRowsFor(registry, ["vllm"])).toEqual([]); + }); + test("invalidates descriptor discovery evidence after an OAuth-only registration", async () => { + authStorage.setRuntimeApiKey("vllm", "fresh-vllm-key"); + using _hook = hookFetch( + () => + new Response(JSON.stringify({ data: [{ id: "fresh-vllm-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("vllm", "online"); + expect(activeRowsFor(registry, ["vllm"])).toEqual([{ provider: "vllm", connectionKind: "credential" }]); + + registry.registerProvider( + "vllm", + { + oauth: { + name: "VLLM", + login: async () => "unused", + }, + }, + "test-vllm-oauth", + ); + + expect(activeRowsFor(registry, ["vllm"])).toEqual([]); + registry.clearSourceRegistrations("test-vllm-oauth"); + }); + test("does not restore descriptor evidence after an in-flight discovery is invalidated", async () => { + authStorage.setRuntimeApiKey("vllm", "fresh-vllm-key"); + const { promise: response, resolve: resolveResponse } = Promise.withResolvers(); + using _hook = hookFetch(input => { + expect(String(input)).toBe("http://127.0.0.1:8000/v1/models"); + return response; + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const refresh = registry.refreshProvider("vllm", "online"); + await Bun.sleep(0); + registry.registerProvider("vllm", { baseUrl: "http://127.0.0.1:9000/v1" }); + resolveResponse( + new Response(JSON.stringify({ data: [{ id: "fresh-vllm-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + await refresh; + + expect(activeRowsFor(registry, ["vllm"])).toEqual([]); + }); + test("does not let an older descriptor refresh overwrite a newer failed probe", async () => { + authStorage.setRuntimeApiKey("vllm", "fresh-vllm-key"); + let calls = 0; + const { promise: olderResponse, resolve: resolveOlder } = Promise.withResolvers(); + const { promise: newerResponse, resolve: resolveNewer } = Promise.withResolvers(); + using _hook = hookFetch(() => (calls++ === 0 ? olderResponse : newerResponse)); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + const olderRefresh = registry.refreshProvider("vllm", "online"); + await Bun.sleep(0); + const newerRefresh = registry.refreshProvider("vllm", "online"); + await Bun.sleep(0); + resolveNewer(new Response("unavailable", { status: 503 })); + await newerRefresh; + resolveOlder( + new Response(JSON.stringify({ data: [{ id: "older-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + await olderRefresh; + expect(readModelCache("vllm", 24 * 60 * 60 * 1000, Date.now, cacheDbPath)?.models).toEqual([]); + + expect(activeRowsFor(registry, ["vllm"])).toEqual([]); + }); + test("invalidates descriptor discovery evidence after a config reload", async () => { + authStorage.setRuntimeApiKey("vllm", "fresh-vllm-key"); + using _hook = hookFetch(input => { + expect(String(input)).toBe("http://127.0.0.1:8000/v1/models"); + return new Response(JSON.stringify({ data: [{ id: "fresh-vllm-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("vllm", "online"); + writeRawModelsJson({ vllm: { baseUrl: "http://127.0.0.1:9000/v1", apiKey: "fresh-vllm-key" } }); + const updatedAt = new Date(Date.now() + 1000); + fs.utimesSync(modelsJsonPath, updatedAt, updatedAt); + await registry.refreshProvider("openai", "offline"); + + expect(activeRowsFor(registry, ["vllm"])).toEqual([]); + }); + test("requires fresh exact discovery evidence while static models stay active", async () => { + let response: "empty" | "unavailable" | "ok" = "empty"; + writeRawModelsJson({ + "discovery-provider": { + baseUrl: "https://discovery.example.com/v1", + api: "openai-responses", + apiKey: "DISCOVERY_KEY", + discovery: { type: "openai-models-list" }, + }, + mixed: { + baseUrl: "https://mixed.example.com/v1", + api: "openai-responses", + auth: "none", + discovery: { type: "openai-models-list" }, + models: [{ id: "mixed-static" }], + }, + "unauthenticated-provider": { + baseUrl: "https://unauthenticated.example.com/v1", + api: "openai-responses", + apiKeyEnv: "GJC_TEST_MISSING_ACTIVE_PROVIDER_KEY", + discovery: { type: "openai-models-list" }, + }, + }); + using _hook = hookFetch(input => { + const url = String(input); + if (url.includes("unauthenticated.example.com")) + throw new Error("unauthenticated discovery must not fetch"); + if (response === "unavailable") return new Response("unavailable", { status: 503 }); + return new Response(JSON.stringify({ data: response === "ok" ? [{ id: "fresh-model" }] : [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + expect(registry.getProviderDiscoveryState("discovery-provider")?.status).toBe("idle"); + expect(activeRowsFor(registry, ["discovery-provider", "mixed"])).toEqual([ + { provider: "mixed", connectionKind: "credentialless" }, + ]); + + await registry.refreshProvider("discovery-provider", "online"); + expect(registry.getProviderDiscoveryState("discovery-provider")?.status).toBe("empty"); + expect(activeRowsFor(registry, ["discovery-provider", "mixed"])).toEqual([ + { provider: "mixed", connectionKind: "credentialless" }, + ]); + + response = "unavailable"; + await registry.refreshProvider("discovery-provider", "online"); + expect(registry.getProviderDiscoveryState("discovery-provider")?.status).toBe("unavailable"); + expect(activeRowsFor(registry, ["discovery-provider", "mixed"])).toEqual([ + { provider: "mixed", connectionKind: "credentialless" }, + ]); + + await registry.refreshProvider("unauthenticated-provider", "online"); + expect(registry.getProviderDiscoveryState("unauthenticated-provider")?.status).toBe("unauthenticated"); + expect(activeRowsFor(registry, ["discovery-provider", "mixed"])).toEqual([ + { provider: "mixed", connectionKind: "credentialless" }, + ]); + + response = "ok"; + await registry.refreshProvider("discovery-provider", "online"); + expect(registry.getProviderDiscoveryState("discovery-provider")?.status).toBe("ok"); + expect(activeRowsFor(registry, ["discovery-provider", "mixed"])).toEqual([ + { provider: "discovery-provider", connectionKind: "credential" }, + { provider: "mixed", connectionKind: "credentialless" }, + ]); + }); + test("normalizes credentialless custom discovery endpoints for Q29", async () => { + let hasModels = true; + writeRawModelsJson({ + "credentialless-discovery": { + baseUrl: "https://credentialless-discovery.example.com", + api: "openai-responses", + auth: "none", + discovery: { type: "openai-models-list" }, + }, + }); + using _hook = hookFetch(input => { + if (String(input) !== "https://credentialless-discovery.example.com/v1/models") { + throw new Error(`Unexpected URL: ${input}`); + } + return new Response(JSON.stringify({ data: hasModels ? [{ id: "discovered-model" }] : [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("credentialless-discovery", "online"); + expect(activeRowsFor(registry, ["credentialless-discovery"])).toEqual([ + { provider: "credentialless-discovery", connectionKind: "credentialless" }, + ]); + + hasModels = false; + await registry.refreshProvider("credentialless-discovery", "online"); + + expect(registry.getProviderDiscoveryState("credentialless-discovery")?.status).toBe("empty"); + expect(registry.find("credentialless-discovery", "discovered-model")).toBeDefined(); + expect(activeRowsFor(registry, ["credentialless-discovery"])).toEqual([]); + }); + test("uses the default endpoint for credentialed custom discovery evidence", async () => { + writeRawModelsJson({ + "default-endpoint-discovery": { + api: "openai-responses", + discovery: { type: "openai-models-list" }, + }, + }); + authStorage.setRuntimeApiKey("default-endpoint-discovery", "credential"); + using _hook = hookFetch(input => { + expect(String(input)).toBe("http://127.0.0.1:1234/v1/models"); + return new Response(JSON.stringify({ data: [{ id: "discovered-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("default-endpoint-discovery", "online"); + + expect(activeRowsFor(registry, ["default-endpoint-discovery"])).toEqual([ + { provider: "default-endpoint-discovery", connectionKind: "credential" }, + ]); + }); + test("does not advertise credentialless cached discovery after a failed probe", async () => { + let unauthorized = false; + writeRawModelsJson({ + "credentialless-discovery": { + baseUrl: "https://credentialless-discovery.example.com/v1", + api: "openai-responses", + auth: "none", + discovery: { type: "openai-models-list" }, + }, + }); + using _hook = hookFetch(() => { + if (unauthorized) return new Response("unauthorized", { status: 401 }); + return new Response(JSON.stringify({ data: [{ id: "discovered-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("credentialless-discovery", "online"); + expect(activeRowsFor(registry, ["credentialless-discovery"])).toEqual([ + { provider: "credentialless-discovery", connectionKind: "credentialless" }, + ]); + + unauthorized = true; + await registry.refreshProvider("credentialless-discovery", "online"); + + expect(registry.getProviderDiscoveryState("credentialless-discovery")?.error).toContain("401"); + expect(activeRowsFor(registry, ["credentialless-discovery"])).toEqual([]); + }); + test("redacts signed discovery endpoint queries from errors", async () => { + writeRawModelsJson({ + "redacted-discovery": { + baseUrl: "https://gateway.example.com/v1?sig=discovery-secret", + api: "openai-responses", + auth: "none", + discovery: { type: "openai-models-list" }, + }, + }); + using _hook = hookFetch(input => { + expect(String(input)).toBe("https://gateway.example.com/v1/models?sig=discovery-secret"); + return new Response("unavailable", { status: 503 }); + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + await registry.refreshProvider("redacted-discovery", "online"); + + const error = registry.getProviderDiscoveryState("redacted-discovery")?.error; + expect(error).toContain("https://gateway.example.com/v1/models"); + expect(error).not.toContain("discovery-secret"); + }); + test("uses refresh-aware OAuth credentials for configured discovery", async () => { + writeRawModelsJson({ + "oauth-discovery": { + baseUrl: "https://oauth-discovery.example.com/v1", + api: "openai-responses", + discovery: { type: "openai-models-list" }, + }, + }); + await authStorage.set("oauth-discovery", [ + { + type: "oauth", + access: "expiring-access", + refresh: "refresh-access", + expires: Date.now() + 30_000, + email: "oauth@example.com", + }, + ]); + let fetchCalls = 0; + let oauthRefreshGeneration = 0; + const getOAuthRefreshGenerationSpy = vi + .spyOn(authStorage, "getProviderOAuthRefreshGeneration") + .mockImplementation(() => oauthRefreshGeneration); + const getApiKeySpy = vi.spyOn(authStorage, "getApiKey").mockImplementationOnce(async () => { + await authStorage.set("oauth-discovery", [ + { + type: "oauth", + access: "refreshed-access", + refresh: "refresh-access", + expires: Date.now() + 60 * 60 * 1000, + email: "oauth@example.com", + }, + ]); + oauthRefreshGeneration += 1; + return "refreshed-access"; + }); + using _hook = hookFetch((input, init) => { + expect(String(input)).toBe("https://oauth-discovery.example.com/v1/models"); + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer refreshed-access"); + fetchCalls += 1; + return new Response(JSON.stringify({ data: [{ id: "oauth-model" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + try { + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refreshProvider("oauth-discovery", "online"); + + expect(getApiKeySpy).toHaveBeenCalledWith("oauth-discovery", undefined, { + baseUrl: "https://oauth-discovery.example.com/v1", + }); + expect(getApiKeySpy).toHaveBeenCalledTimes(1); + expect(fetchCalls).toBe(1); + } finally { + getApiKeySpy.mockRestore(); + getOAuthRefreshGenerationSpy.mockRestore(); + } + }); + test("discards configured discovery when a runtime credential changes during OAuth preflight", async () => { + writeRawModelsJson({ + "oauth-discovery": { + baseUrl: "https://oauth-discovery.example.com/v1", + api: "openai-responses", + discovery: { type: "openai-models-list" }, + }, + }); + await authStorage.set("oauth-discovery", [ + { + type: "oauth", + access: "expiring-access", + refresh: "refresh-access", + expires: Date.now() + 30_000, + email: "oauth@example.com", + }, + ]); + let oauthRefreshGeneration = 0; + const getOAuthRefreshGenerationSpy = vi + .spyOn(authStorage, "getProviderOAuthRefreshGeneration") + .mockImplementation(() => oauthRefreshGeneration); + const getApiKeySpy = vi.spyOn(authStorage, "getApiKey").mockImplementationOnce(async () => { + await authStorage.set("oauth-discovery", [ + { + type: "oauth", + access: "refreshed-access", + refresh: "refresh-access", + expires: Date.now() + 60 * 60 * 1000, + email: "oauth@example.com", + }, + ]); + oauthRefreshGeneration += 1; + authStorage.setRuntimeApiKey("oauth-discovery", "runtime-access"); + return "refreshed-access"; + }); + using _hook = hookFetch(() => { + throw new Error("stale OAuth preflight must not start discovery"); + }); + try { + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + await registry.refreshProvider("oauth-discovery", "online"); + + expect(getApiKeySpy).toHaveBeenCalledTimes(1); + expect(registry.find("oauth-discovery", "oauth-model")).toBeUndefined(); + expect(readModelCache("oauth-discovery", 24 * 60 * 60 * 1000, Date.now, cacheDbPath)).toBeNull(); + } finally { + getApiKeySpy.mockRestore(); + getOAuthRefreshGenerationSpy.mockRestore(); + } + }); + test("keeps configured discovery provider-local when OAuth preflight fails", async () => { + writeRawModelsJson({ + "failing-oauth-discovery": { + baseUrl: "https://failing-oauth.example.com/v1", + api: "openai-responses", + discovery: { type: "openai-models-list" }, + }, + }); + await authStorage.set("failing-oauth-discovery", [ + { + type: "oauth", + access: "expiring-access", + refresh: "refresh-access", + expires: Date.now() + 30_000, + email: "oauth@example.com", + }, + ]); + authStorage.setRuntimeCredentialSelector("failing-oauth-discovery", { + kind: "email", + value: "oauth@example.com", + }); + const getApiKeySpy = vi + .spyOn(authStorage, "getApiKey") + .mockRejectedValue(new Error("OAuth refresh unavailable")); + try { + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + await expect(registry.refreshProvider("failing-oauth-discovery", "online")).resolves.toBeUndefined(); + + expect(registry.getProviderDiscoveryState("failing-oauth-discovery")?.status).toBe("unauthenticated"); + expect(activeRowsFor(registry, ["failing-oauth-discovery"])).toEqual([]); + } finally { + getApiKeySpy.mockRestore(); + } + }); }); }); diff --git a/packages/coding-agent/test/resolve-config-value.test.ts b/packages/coding-agent/test/resolve-config-value.test.ts new file mode 100644 index 0000000000..c977b81de7 --- /dev/null +++ b/packages/coding-agent/test/resolve-config-value.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { clearConfigValueCache, resolveConfigValue } from "../src/config/resolve-config-value"; + +test("isolates command cache entries by caller scope", async () => { + clearConfigValueCache(); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-resolve-config-value-")); + const counterPath = path.join(tempDir, "counter"); + await Bun.write(counterPath, "0"); + const command = `!count=$(cat "${counterPath}"); next=$((count + 1)); printf %s "$next" > "${counterPath}"; printf %s "$next"`; + + try { + await expect(resolveConfigValue(command, "first-credential")).resolves.toBe("1"); + await expect(resolveConfigValue(command, "first-credential")).resolves.toBe("1"); + await expect(resolveConfigValue(command, "replacement-credential")).resolves.toBe("2"); + } finally { + clearConfigValueCache(); + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); diff --git a/packages/coding-agent/test/sdk-adapter-dispositions.test.ts b/packages/coding-agent/test/sdk-adapter-dispositions.test.ts index 68358d931b..c65017ff5e 100644 --- a/packages/coding-agent/test/sdk-adapter-dispositions.test.ts +++ b/packages/coding-agent/test/sdk-adapter-dispositions.test.ts @@ -28,7 +28,7 @@ const parityRows = ( rows: ParityRow[]; } ).rows; -expect(parityRows).toHaveLength(570); +expect(parityRows).toHaveLength(576); const parityPrefix: Record = { telegram: "T", discord: "D", diff --git a/packages/coding-agent/test/sdk-host-wiring.test.ts b/packages/coding-agent/test/sdk-host-wiring.test.ts index 0865e232d9..d3bd390686 100644 --- a/packages/coding-agent/test/sdk-host-wiring.test.ts +++ b/packages/coding-agent/test/sdk-host-wiring.test.ts @@ -235,6 +235,7 @@ function context( }, }, ], + getActiveProviders: () => [{ provider: "fixture-provider", connectionKind: "credential" }], }, getSystemPrompt: () => ["test"], isIdle: () => live.idle ?? true, @@ -2623,6 +2624,21 @@ test("SDK host binds session query and control seams and excludes uninstalled re const response = await request(`query-${query}`, { type: "query_request", id: `query-${query}`, query }); expect(response).toMatchObject({ ok: true, page: { items: [expect.objectContaining(expected)] } }); } + const activeProviders = await request("query-Q29", { + type: "query_request", + id: "query-Q29", + query: "Q29", + }); + expect(activeProviders).toEqual({ + type: "query_response", + id: "query-Q29", + ok: true, + page: { + items: [{ provider: "fixture-provider", connectionKind: "credential" }], + complete: true, + revision: "1", + }, + }); for (const query of ["Q10", "models.list/current", "models.list", "models.current"]) { const response = await request(`query-${query}`, { type: "query_request", diff --git a/packages/coding-agent/test/sdk-operation-inventory.test.ts b/packages/coding-agent/test/sdk-operation-inventory.test.ts index 87db5ec903..f41bfe75f3 100644 --- a/packages/coding-agent/test/sdk-operation-inventory.test.ts +++ b/packages/coding-agent/test/sdk-operation-inventory.test.ts @@ -36,7 +36,7 @@ describe("SDK operation inventory", () => { it("has complete typed operation and adapter coverage", () => { expect(OPERATIONS.filter(operation => operation.kind === "control")).toHaveLength(53); expect(OPERATIONS.filter(operation => operation.kind === "global")).toHaveLength(7); - expect(OPERATIONS.filter(operation => operation.kind === "query")).toHaveLength(28); + expect(OPERATIONS.filter(operation => operation.kind === "query")).toHaveLength(29); expect(OPERATIONS.filter(operation => operation.kind === "reverse")).toHaveLength(6); for (const operation of OPERATIONS) { expect(Object.keys(operation.adapterDispositions).sort()).toEqual([...ADAPTERS].sort()); diff --git a/packages/coding-agent/test/sdk-operation-matrix.test.ts b/packages/coding-agent/test/sdk-operation-matrix.test.ts index f1865852f1..076d6986b2 100644 --- a/packages/coding-agent/test/sdk-operation-matrix.test.ts +++ b/packages/coding-agent/test/sdk-operation-matrix.test.ts @@ -43,6 +43,14 @@ const expectedDispositions: Record> = acp: "generic_safe", daemonCli: "generic_safe", }, + Q29: { + telegram: "prohibited", + discord: "prohibited", + slack: "prohibited", + mcp: "generic_safe", + acp: "generic_safe", + daemonCli: "generic_safe", + }, C52: { telegram: "prohibited", discord: "prohibited", @@ -83,7 +91,7 @@ describe("SDK operation matrix", () => { const registryById = new Map(OPERATIONS.map(operation => [operation.id, operation])); const inventoryIds = registryInventory.map(row => row.sourceId.replace("registry:", "")); expect(new Set(inventoryIds)).toEqual(new Set(registryById.keys())); - expect(registryInventory).toHaveLength(94); + expect(registryInventory).toHaveLength(95); for (const row of registryInventory) { const id = row.sourceId.startsWith("registry:") ? row.sourceId.slice("registry:".length) : row.sourceId; @@ -102,7 +110,7 @@ describe("SDK operation matrix", () => { it("keeps control errors, query continuity, counts, and the stage-05 adapter partition explicit", () => { expect(OPERATIONS.filter(operation => operation.kind === "control")).toHaveLength(53); expect(OPERATIONS.filter(operation => operation.kind === "global")).toHaveLength(7); - expect(OPERATIONS.filter(operation => operation.kind === "query")).toHaveLength(28); + expect(OPERATIONS.filter(operation => operation.kind === "query")).toHaveLength(29); expect(OPERATIONS.filter(operation => operation.kind === "reverse")).toHaveLength(6); for (const operation of OPERATIONS.filter(operation => operation.kind === "control")) expect(operation.errorCodes.length).toBeGreaterThan(0); diff --git a/packages/coding-agent/test/sdk-package-exports.test.ts b/packages/coding-agent/test/sdk-package-exports.test.ts index 491c144cb1..b17e6dc2fe 100644 --- a/packages/coding-agent/test/sdk-package-exports.test.ts +++ b/packages/coding-agent/test/sdk-package-exports.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as path from "node:path"; import type { + ActiveProviderConnectionKind, + ActiveProviderDescriptor, ModelProfileCatalogItem, ModelProfileErrorDetails, Q10CurrentThinkingLevel, @@ -39,7 +41,10 @@ const sdkCapabilityDtoTypes: ] | undefined = undefined; +const q29DtoTypes: [ActiveProviderDescriptor, ActiveProviderConnectionKind] | undefined = undefined; + void sdkCapabilityDtoTypes; +void q29DtoTypes; describe("SDK package exports", () => { it("preserves the session SDK surface and bus namespace after the namespace move", () => { @@ -63,6 +68,8 @@ describe("SDK package exports", () => { "@gajae-code/coding-agent/sdk/lifecycle-session.js", "@gajae-code/coding-agent/sdk/startup-capability", "@gajae-code/coding-agent/sdk/startup-capability.js", + "@gajae-code/coding-agent/sdk/providers", + "@gajae-code/coding-agent/sdk/providers.js", ])("rejects resolution of the private %s subpath", async subpath => { const child = Bun.spawn([process.execPath, "-e", `await import(${JSON.stringify(subpath)})`], { cwd: import.meta.dir, @@ -115,6 +122,8 @@ describe("SDK package exports", () => { "./sdk/lifecycle-session.js", "./sdk/startup-capability", "./sdk/startup-capability.js", + "./sdk/providers", + "./sdk/providers.js", ] as const) expect(packageJson.exports[subpath]).toBeNull(); }); diff --git a/packages/coding-agent/test/sdk-q29-active-providers.test.ts b/packages/coding-agent/test/sdk-q29-active-providers.test.ts new file mode 100644 index 0000000000..b6fd962f3b --- /dev/null +++ b/packages/coding-agent/test/sdk-q29-active-providers.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "bun:test"; +import { CursorRegistry } from "../src/sdk/host/query/cursor.js"; +import { QueryHandlers, type SessionSurface } from "../src/sdk/host/query/handlers.js"; +import { RevisionStore } from "../src/sdk/host/query/revision-store.js"; +import { findOperation } from "../src/sdk/protocol/operation-registry.js"; +import { projectActiveProviderDescriptors } from "../src/sdk/providers.js"; + +function surface(getActiveProviders: SessionSurface["getActiveProviders"]): SessionSurface { + return { + getTranscriptEntries: () => [], + getContextSnapshot: () => ({}), + getGoalState: () => undefined, + getTodoState: () => [], + getDiff: () => [], + getUsage: () => ({}), + getModels: () => [], + getSkillState: () => [], + getActiveProviders, + getGates: () => [], + getConfigItems: () => [], + getSessionMetadata: () => ({}), + getStats: () => ({}), + getBranchCandidates: () => [], + getLastAssistant: () => undefined, + getCapabilities: () => ({}), + getAuthProviders: () => [], + getTools: () => [], + getQueueMessages: () => [], + getExtensions: () => [], + getJobs: () => [], + installedQueries: new Set(["providers.list/active", "models.list/current"]), + }; +} + +async function queryActiveProviders( + getActiveProviders: SessionSurface["getActiveProviders"], + id?: string, + input?: Record, +) { + const revisions = new RevisionStore("session"); + const cursors = new CursorRegistry("token", revisions); + const handlers = new QueryHandlers(surface(getActiveProviders), "session", revisions, cursors); + return handlers.dispatch({ + query: "providers.list/active", + connectionId: "connection", + ...(id ? { id } : {}), + ...(input ? { input } : {}), + }); +} + +describe("Q29 providers.list/active", () => { + it("projects one minimal descriptor per provider through the standard snapshot page", async () => { + const response = await queryActiveProviders(() => [ + { provider: "anthropic", connectionKind: "credential" }, + { provider: "openai", connectionKind: "credentialless" }, + ]); + + expect(response).toEqual({ + id: undefined, + ok: true, + page: { + items: [ + { provider: "anthropic", connectionKind: "credential" }, + { provider: "openai", connectionKind: "credentialless" }, + ], + complete: true, + revision: "1", + }, + }); + expect(response.page?.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ provider: "anthropic" }), + expect.objectContaining({ provider: "openai" }), + ]), + ); + for (const item of response.page?.items ?? []) + expect(Object.keys(item as object).sort()).toEqual(["connectionKind", "provider"]); + }); + it("strips unexpected fields, deduplicates with credential precedence, and sorts by UTF-8", () => { + const input = [ + { provider: "zeta", connectionKind: "credentialless", unexpected: "secret" }, + { provider: "zeta", connectionKind: "credential", dynamic: { token: "secret" } }, + { provider: "alpha", connectionKind: "credential", extra: true }, + { provider: "é", connectionKind: "credentialless", partial: "secret" }, + ]; + expect(projectActiveProviderDescriptors(input)).toEqual([ + { provider: "alpha", connectionKind: "credential" }, + { provider: "zeta", connectionKind: "credential" }, + { provider: "é", connectionKind: "credentialless" }, + ]); + expect(() => projectActiveProviderDescriptors([{ provider: "invalid", connectionKind: "unsupported" }])).toThrow( + "Invalid active provider connection kind.", + ); + }); + + it("maps resolver failures to the fixed safe error and preserves only the request id", async () => { + const response = await queryActiveProviders(() => { + throw new Error("credential=super-secret dynamic details"); + }, "request-26"); + + expect(response).toEqual({ + id: "request-26", + ok: false, + error: { code: "internal", message: "Unable to resolve active providers." }, + }); + expect(response).not.toHaveProperty("page"); + expect(response).not.toHaveProperty("restartQuery"); + expect(JSON.stringify(response)).not.toContain("super-secret"); + }); + it("maps resolver failures without a request id to the fixed safe error", async () => { + const response = await queryActiveProviders(() => { + throw new Error("dynamic credentials and partial details"); + }); + + expect(response.id).toBeUndefined(); + expect(response.ok).toBe(false); + expect(response.error).toEqual({ + code: "internal", + message: "Unable to resolve active providers.", + }); + expect(response).not.toHaveProperty("page"); + expect(response).not.toHaveProperty("restartQuery"); + expect(response).not.toHaveProperty("partial"); + expect(JSON.stringify(response)).not.toContain("dynamic"); + expect(JSON.stringify(response)).not.toContain("credentials"); + }); + it("rejects input fields", async () => { + const response = await queryActiveProviders(() => [], undefined, { provider: "anthropic" }); + + expect(response).toEqual({ + id: undefined, + ok: false, + error: { code: "invalid_request", message: "providers.list/active does not accept input fields." }, + }); + }); + + it("registers Q29 as an append-only generic-safe scalar snapshot query", () => { + const operation = findOperation("query", "providers.list/active"); + expect(operation).toMatchObject({ + id: "Q29", + sdkId: "providers.list/active", + idempotency: "idempotent", + continuityClass: "scalar_snapshot", + errorCodes: ["invalid_request", "resource_gone", "internal"], + }); + expect(operation?.adapterDispositions).toEqual({ + telegram: "prohibited", + discord: "prohibited", + slack: "prohibited", + mcp: "generic_safe", + acp: "generic_safe", + daemonCli: "generic_safe", + }); + }); +}); diff --git a/packages/coding-agent/test/sdk-query-pagination.test.ts b/packages/coding-agent/test/sdk-query-pagination.test.ts index 42b6fadf9c..8f9d28d98e 100644 --- a/packages/coding-agent/test/sdk-query-pagination.test.ts +++ b/packages/coding-agent/test/sdk-query-pagination.test.ts @@ -4,8 +4,9 @@ import { mkdtemp, readdir, readFile, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BrokerWorkflowGateEmitter, FileGateStore } from "../src/modes/shared/agent-wire/workflow-gate-broker"; -import { CursorRegistry, cursorMac, QueryHandlers, RevisionStore } from "../src/sdk/host/query/index.js"; +import { CURSOR_TTL_MS, CursorRegistry, cursorMac, QueryHandlers, RevisionStore } from "../src/sdk/host/query/index.js"; import { Q10ThinkingMetadataError } from "../src/sdk/models.js"; +import type { ActiveProviderDescriptor } from "../src/sdk/providers.js"; const huge = (value: string) => `${value}${"x".repeat(140_000)}`; function surface(transcript: unknown[] = []) { @@ -18,6 +19,7 @@ function surface(transcript: unknown[] = []) { getUsage: () => ({}), getModels: () => [], getSkillState: () => [], + getActiveProviders: () => [], getGates: () => [], getConfigItems: () => [], getSessionMetadata: () => ({}), @@ -41,6 +43,185 @@ function handlers(transcript: unknown[]) { } describe("SDK query pagination", () => { + it("returns the exact Q29 standard page in surface order and enforces installed-query authority", async () => { + const activeProviders: ActiveProviderDescriptor[] = [ + { provider: "anthropic", connectionKind: "credential" }, + { provider: "openai", connectionKind: "credentialless" }, + ]; + const store = new RevisionStore("s1"); + const query = new QueryHandlers( + { + ...surface(), + getActiveProviders: () => activeProviders, + installedQueries: new Set(["providers.list/active"]), + }, + "s1", + store, + new CursorRegistry("token", store), + ); + const response = await query.dispatch({ query: "providers.list/active", id: "q29", connectionId: "c" }); + expect(response).toEqual({ + id: "q29", + ok: true, + page: { items: activeProviders, complete: true, revision: "1" }, + }); + + const notInstalled = new QueryHandlers( + { + ...surface(), + getActiveProviders: () => activeProviders, + installedQueries: new Set(["models.list/current"]), + }, + "s1", + store, + new CursorRegistry("token", store), + ); + const rejected = await notInstalled.dispatch({ query: "providers.list/active", id: "q29", connectionId: "c" }); + expect(rejected).toMatchObject({ + id: "q29", + ok: false, + error: { + code: "operation_not_session_owned", + message: "providers.list/active is not installed for this session.", + }, + }); + expect(rejected.page).toBeUndefined(); + }); + + it("keeps Q29 continuation order and snapshot contents after the surface mutates", async () => { + const initialProviders: ActiveProviderDescriptor[] = [ + { provider: huge("anthropic"), connectionKind: "credential" }, + { provider: huge("openai"), connectionKind: "credentialless" }, + { provider: huge("google"), connectionKind: "credential" }, + ]; + const activeProviders = [...initialProviders]; + const store = new RevisionStore("s1"); + const query = new QueryHandlers( + { + ...surface(), + getActiveProviders: () => activeProviders, + installedQueries: new Set(["providers.list/active"]), + }, + "s1", + store, + new CursorRegistry("token", store), + ); + + const first = await query.dispatch({ query: "providers.list/active", connectionId: "c" }); + expect(first.page).toMatchObject({ + items: [initialProviders[0]], + complete: false, + revision: "1", + preview: true, + }); + expect(first.page?.continuationCursor).toEqual(expect.any(String)); + + activeProviders[1] = { provider: "mutated", connectionKind: "credential" }; + const second = await query.dispatch({ + query: "providers.list/active", + cursor: first.page?.continuationCursor, + connectionId: "c", + }); + expect(second.page).toMatchObject({ + items: [initialProviders[1]], + complete: false, + revision: "1", + preview: true, + }); + + const third = await query.dispatch({ + query: "providers.list/active", + cursor: second.page?.continuationCursor, + connectionId: "c", + }); + expect(third.page?.items).toEqual([initialProviders[2]]); + expect(third.page?.complete).toBe(true); + expect( + [...(first.page?.items ?? []), ...(second.page?.items ?? []), ...(third.page?.items ?? [])].map( + item => (item as { provider: string }).provider, + ), + ).toEqual(initialProviders.map(item => item.provider)); + }); + it("returns shared restart metadata when a Q29 continuation expires", async () => { + let now = 1_000; + const activeProviders: ActiveProviderDescriptor[] = [ + { provider: huge("provider-one"), connectionKind: "credential" }, + { provider: huge("provider-two"), connectionKind: "credentialless" }, + ]; + const store = new RevisionStore("s1", () => now); + const query = new QueryHandlers( + { + ...surface(), + getActiveProviders: () => activeProviders, + installedQueries: new Set(["providers.list/active"]), + }, + "s1", + store, + new CursorRegistry("token", store, () => now), + ); + + const first = await query.dispatch({ query: "providers.list/active", connectionId: "c" }); + expect(first.page?.complete).toBe(false); + now += CURSOR_TTL_MS + 1; + const expired = await query.dispatch({ + query: "providers.list/active", + cursor: first.page?.continuationCursor, + connectionId: "c", + }); + + expect(expired).toMatchObject({ + ok: false, + error: { code: "cursor_expired", message: "cursor_expired", restartQuery: true }, + }); + expect(expired.page).toBeUndefined(); + }); + + it("rejects Q10 and Q29 cursor reuse across query resources", async () => { + const models = [ + { id: "model-one", name: huge("model-one") }, + { id: "model-two", name: huge("model-two") }, + ]; + const activeProviders: ActiveProviderDescriptor[] = [ + { provider: huge("provider-one"), connectionKind: "credential" }, + { provider: huge("provider-two"), connectionKind: "credentialless" }, + ]; + const store = new RevisionStore("s1"); + const query = new QueryHandlers( + { + ...surface(), + getModels: () => models, + getActiveProviders: () => activeProviders, + installedQueries: new Set(["models.list/current", "providers.list/active"]), + }, + "s1", + store, + new CursorRegistry("token", store), + ); + + const q10First = await query.dispatch({ query: "Q10", connectionId: "c" }); + expect(q10First.page?.complete).toBe(false); + const q29WithQ10Cursor = await query.dispatch({ + query: "providers.list/active", + cursor: q10First.page?.continuationCursor, + connectionId: "c", + }); + expect(q29WithQ10Cursor.error).toMatchObject({ + code: "invalid_input", + message: "cursor does not match query", + }); + + const q29First = await query.dispatch({ query: "providers.list/active", connectionId: "c" }); + expect(q29First.page?.complete).toBe(false); + const q10WithQ29Cursor = await query.dispatch({ + query: "Q10", + cursor: q29First.page?.continuationCursor, + connectionId: "c", + }); + expect(q10WithQ29Cursor.error).toMatchObject({ + code: "invalid_input", + message: "cursor does not match query", + }); + }); it("keeps transcript pages to their first stable prefix while entries append", async () => { const transcript = [ { id: "one", body: huge("one") },