From 43a9d794ec9459e543890a8a2be66ec25957c560 Mon Sep 17 00:00:00 2001 From: tomllt Date: Fri, 5 Jun 2026 22:19:30 +0800 Subject: [PATCH 1/3] fix(gsd): honor custom preferred models in auto-start --- src/resources/extensions/gsd/auto-start.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts index 9e18214ef9..68a330401c 100644 --- a/src/resources/extensions/gsd/auto-start.ts +++ b/src/resources/extensions/gsd/auto-start.ts @@ -998,15 +998,17 @@ export async function bootstrapAutoSession( // // Exception (#4122): when the session provider is a custom provider declared // in ~/.gsd/agent/models.json (Ollama, vLLM, OpenAI-compatible proxy, etc.), - // PREFERENCES.md is skipped entirely. PREFERENCES.md cannot reference custom - // providers, so honoring it would silently reroute auto-mode to a built-in - // provider the user is not logged into and surface as "Not logged in ยท Please - // run /login" before pausing and resetting to claude-code/claude-sonnet-4-6. + // ignore PREFERENCES.md only if it points at a built-in provider. This keeps + // custom-provider sessions from being silently rerouted to an unconfigured + // built-in provider, while still allowing PREFERENCES.md entries that also + // target a custom provider to participate in auto-start snapshot selection. const manualSessionOverride = getSessionModelOverride(ctx.sessionManager.getSessionId()); const sessionProviderIsCustom = isCustomProvider(ctx.model?.provider); - const preferredModel = sessionProviderIsCustom - ? null - : resolveDefaultSessionModel(ctx.model?.provider); + const rawPreferredModel = resolveDefaultSessionModel(ctx.model?.provider); + const preferredModel = + sessionProviderIsCustom && rawPreferredModel && !isCustomProvider(rawPreferredModel.provider) + ? null + : rawPreferredModel; // Validate the preferred model against the live registry + provider auth so // an unconfigured PREFERENCES.md entry (no API key / OAuth) can't become the // start-model snapshot. Without this, every subsequent unit would try to From ba1f437a6807d3b2936a24d5b6aaccac9a5fae29 Mon Sep 17 00:00:00 2001 From: tomllt Date: Sat, 6 Jun 2026 23:40:30 +0800 Subject: [PATCH 2/3] test: cover anthropic provider override isolation --- .../test/model-registry.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/pi-coding-agent/test/model-registry.test.ts b/packages/pi-coding-agent/test/model-registry.test.ts index d4e6f62110..04fef6a90a 100644 --- a/packages/pi-coding-agent/test/model-registry.test.ts +++ b/packages/pi-coding-agent/test/model-registry.test.ts @@ -1063,6 +1063,58 @@ describe("ModelRegistry", () => { expect(() => streamSimple(scopedModel, emptyContext)).not.toThrow("custom streamSimple override"); }); + test("anthropic-compatible provider override does not hijack other providers sharing anthropic-messages", async () => { + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + + registry.registerProvider("custom-anthropic-wrapper", { + api: "anthropic-messages", + baseUrl: "https://wrapper.test/anthropic", + apiKey: "TEST_KEY", + models: [ + { + id: "wrapper-model", + name: "Wrapper Model", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + }, + ], + streamSimple: () => { + throw new Error("wrapper streamSimple override"); + }, + }); + + const wrapperModel = registry.find("custom-anthropic-wrapper", "wrapper-model"); + if (!wrapperModel) { + throw new Error("Expected wrapper model to be registered"); + } + const anthropicModel: Model = { + id: "test-anthropic-model", + name: "Test Anthropic Model", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 4096, + }; + + await expect(streamSimple(wrapperModel, emptyContext).result()).resolves.toMatchObject({ + provider: "custom-anthropic-wrapper", + model: "wrapper-model", + errorMessage: "No API key for provider: custom-anthropic-wrapper", + }); + await expect( + getApiProvider("anthropic-messages")?.streamSimple(anthropicModel, emptyContext).result(), + ).resolves.not.toMatchObject({ + provider: "custom-anthropic-wrapper", + }); + }); + describe("dynamic provider override persistence", () => { test("baseUrl-only override keeps built-in provider models after refresh", () => { const registry = ModelRegistry.create(authStorage, modelsJsonPath); From c27f29514d108a960c8d1974738ce99abfdd0956 Mon Sep 17 00:00:00 2001 From: tomllt Date: Tue, 16 Jun 2026 14:13:04 +0800 Subject: [PATCH 3/3] fix: fallback on retryable model errors --- .../gsd-agent-core/src/agent-session.test.ts | 129 ++++++++++++++++++ packages/gsd-agent-core/src/agent-session.ts | 7 + .../gsd-agent-core/src/fallback-resolver.ts | 22 ++- packages/gsd-agent-core/src/sdk.ts | 2 + .../src/session/agent-session-host.ts | 2 + .../src/session/agent-session-prompt.ts | 39 +++++- .../src/session/agent-session-types.ts | 3 + 7 files changed, 198 insertions(+), 6 deletions(-) diff --git a/packages/gsd-agent-core/src/agent-session.test.ts b/packages/gsd-agent-core/src/agent-session.test.ts index 4f7251672e..6afbc07084 100644 --- a/packages/gsd-agent-core/src/agent-session.test.ts +++ b/packages/gsd-agent-core/src/agent-session.test.ts @@ -2,6 +2,8 @@ import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { parseSkillBlock } from "./agent-session.ts"; import { AgentSessionExtensionsModule } from "./session/agent-session-extensions.ts"; +import { AgentSessionPromptModule } from "./session/agent-session-prompt.ts"; +import { FallbackResolver } from "./fallback-resolver.ts"; describe("parseSkillBlock", () => { test("parses a valid skill block with trailing user message", () => { @@ -53,6 +55,123 @@ describe("AgentSessionExtensionsModule", () => { }); }); +describe("AgentSessionPromptModule", () => { + test("switches to a configured fallback model for retryable 502 errors", async () => { + const primary = makeModel("primary-provider", "primary-model"); + const fallback = makeModel("fallback-provider", "fallback-model"); + const events: unknown[] = []; + let selectedModel: unknown; + let fallbackErrorType: string | undefined; + + const host = { + model: primary, + settingsManager: { + getRetrySettings: () => ({ enabled: true, maxRetries: 0, baseDelayMs: 1 }), + }, + fallbackResolver: { + async findFallback(currentModel: unknown, errorType: string) { + fallbackErrorType = errorType; + assert.equal(currentModel, primary); + return { + model: fallback, + chainName: "default", + reason: "falling back to fallback-provider/fallback-model", + }; + }, + }, + agent: { + state: { + model: primary, + messages: [ + { + role: "assistant", + stopReason: "error", + errorMessage: "502 status code (no body)", + }, + ], + }, + }, + _lastAssistantMessage: { + role: "assistant", + stopReason: "error", + errorMessage: "502 status code (no body)", + }, + _retryAttempt: 0, + emit: (event: unknown) => events.push(event), + setModel: async (model: unknown) => { + selectedModel = model; + host.agent.state.model = model as typeof primary; + }, + checkCompaction: async () => false, + }; + + const shouldContinue = await new AgentSessionPromptModule(host as any).handlePostAgentRun(); + + assert.equal(shouldContinue, true); + assert.equal(selectedModel, fallback); + assert.equal(host.agent.state.model, fallback); + assert.equal(fallbackErrorType, "retryable"); + assert.deepEqual(host.agent.state.messages, []); + assert.deepEqual(events, [ + { + type: "fallback_provider_switch", + fromProvider: "primary-provider", + toProvider: "fallback-provider", + model: fallback, + reason: "falling back to fallback-provider/fallback-model", + }, + ]); + }); +}); + +describe("FallbackResolver", () => { + test("allows fallback to a backup model on the same provider", async () => { + const primary = makeModel("shared-provider", "primary-model"); + const backup = makeModel("shared-provider", "backup-model"); + let exhaustedProvider: string | undefined; + + const resolver = new FallbackResolver( + { + getFallbackSettings: () => ({ + enabled: true, + chains: { + sameProvider: [ + { provider: "shared-provider", model: "primary-model" }, + { provider: "shared-provider", model: "backup-model" }, + ], + }, + }), + } as any, + { + markProviderExhausted(provider: string) { + exhaustedProvider = provider; + }, + isProviderAvailable() { + return exhaustedProvider === undefined; + }, + } as any, + { + find(provider: string, model: string) { + if (provider === "shared-provider" && model === "primary-model") return primary; + if (provider === "shared-provider" && model === "backup-model") return backup; + return undefined; + }, + isProviderRequestReady() { + return true; + }, + } as any, + ); + + const result = await resolver.findFallback(primary, "retryable"); + + assert.deepEqual(result, { + model: backup, + chainName: "sameProvider", + reason: "falling back to shared-provider/backup-model", + }); + }); +}); + function makeSkill(name: string) { return { name, @@ -64,3 +183,13 @@ function makeSkill(name: string) { disableModelInvocation: false, }; } + +function makeModel(provider: string, id: string) { + return { + api: "openai-responses", + provider, + id, + name: id, + contextWindow: 128000, + }; +} diff --git a/packages/gsd-agent-core/src/agent-session.ts b/packages/gsd-agent-core/src/agent-session.ts index 73ae5e7416..6ef0d37e15 100644 --- a/packages/gsd-agent-core/src/agent-session.ts +++ b/packages/gsd-agent-core/src/agent-session.ts @@ -71,6 +71,7 @@ import { type TurnLatencyVisibleKind, } from "./turn-latency.js"; import type { AgentSessionHost } from "./session/agent-session-host.js"; +import type { FallbackResolver } from "./fallback-resolver.js"; import { AgentSessionEventsModule } from "./session/agent-session-events.js"; import { AgentSessionPromptModule } from "./session/agent-session-prompt.js"; import { AgentSessionModelModule } from "./session/agent-session-model.js"; @@ -148,6 +149,7 @@ export class AgentSession implements AgentSessionHost { _baseSystemPromptOptions!: BuildSystemPromptOptions; _lastAssistantMessage: AssistantMessage | undefined = undefined; _activeTurnLatency: TurnLatencyRecord | undefined = undefined; + _fallbackResolver: FallbackResolver | undefined = undefined; private readonly _events = new AgentSessionEventsModule(this); private readonly _prompt = new AgentSessionPromptModule(this); @@ -171,6 +173,7 @@ export class AgentSession implements AgentSessionHost { this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined; this._baseToolsOverride = config.baseToolsOverride; this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" }; + this._fallbackResolver = config.fallbackResolver; this._unsubscribeAgent = this.agent.subscribe(this._events.handleAgentEvent); this._extensions.installAgentToolHooks(); @@ -297,6 +300,10 @@ export class AgentSession implements AgentSessionHost { return this._extensionRunner; } + get fallbackResolver(): FallbackResolver | undefined { + return this._fallbackResolver; + } + // AgentSessionHost cross-module surface emit(event: AgentSessionEvent): void { this._events.emit(event); diff --git a/packages/gsd-agent-core/src/fallback-resolver.ts b/packages/gsd-agent-core/src/fallback-resolver.ts index 6c5086f459..fca20ac0a6 100644 --- a/packages/gsd-agent-core/src/fallback-resolver.ts +++ b/packages/gsd-agent-core/src/fallback-resolver.ts @@ -53,11 +53,23 @@ export class FallbackResolver { if (currentIndex === -1) continue; // Try entries after the current one (already sorted by priority) - const result = await this._findAvailableInChain(chainName, entries, currentIndex + 1); + const result = await this._findAvailableInChain( + chainName, + entries, + currentIndex + 1, + undefined, + currentModel.provider, + ); if (result) return result; // Wrap around: try entries before the current one - const wrapResult = await this._findAvailableInChain(chainName, entries, 0, currentIndex); + const wrapResult = await this._findAvailableInChain( + chainName, + entries, + 0, + currentIndex, + currentModel.provider, + ); if (wrapResult) return wrapResult; } @@ -134,6 +146,7 @@ export class FallbackResolver { entries: FallbackChainEntry[], startIndex: number, endIndex?: number, + allowBackedOffProvider?: string, ): Promise { const end = endIndex ?? entries.length; @@ -141,7 +154,10 @@ export class FallbackResolver { const entry = entries[i]; // Check provider-level backoff - if (!this.authStorage.isProviderAvailable(entry.provider)) { + if ( + entry.provider !== allowBackedOffProvider && + !this.authStorage.isProviderAvailable(entry.provider) + ) { continue; } diff --git a/packages/gsd-agent-core/src/sdk.ts b/packages/gsd-agent-core/src/sdk.ts index 2373dfa8d5..06dbadc66f 100644 --- a/packages/gsd-agent-core/src/sdk.ts +++ b/packages/gsd-agent-core/src/sdk.ts @@ -4,6 +4,7 @@ import { clampThinkingLevel, type Message, type Model, streamSimple } from "@gsd import { getAgentDir } from "@gsd/pi-coding-agent/config.js"; import { resolvePath } from "@gsd/pi-coding-agent/utils/paths.js"; import { AgentSession } from "./agent-session.js"; +import { FallbackResolver } from "./fallback-resolver.js"; import { formatNoModelsAvailableMessage } from "@gsd/pi-coding-agent/core/auth-guidance.js"; import { AuthStorage } from "@gsd/pi-coding-agent/core/auth-storage.js"; import { DEFAULT_THINKING_LEVEL } from "@gsd/pi-coding-agent/core/defaults.js"; @@ -428,6 +429,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} allowedToolNames, extensionRunnerRef, sessionStartEvent: options.sessionStartEvent, + fallbackResolver: new FallbackResolver(settingsManager, authStorage, modelRegistry), }); const extensionsResult = resourceLoader.getExtensions(); diff --git a/packages/gsd-agent-core/src/session/agent-session-host.ts b/packages/gsd-agent-core/src/session/agent-session-host.ts index 035e7c06a2..2aa6ad6161 100644 --- a/packages/gsd-agent-core/src/session/agent-session-host.ts +++ b/packages/gsd-agent-core/src/session/agent-session-host.ts @@ -45,6 +45,7 @@ import type { TurnLatencyStatus, TurnLatencyVisibleKind, } from "../turn-latency.js"; +import type { FallbackResolver } from "../fallback-resolver.js"; /** * Internal surface shared by AgentSession submodule classes. @@ -56,6 +57,7 @@ export interface AgentSessionHost { readonly settingsManager: SettingsManager; readonly modelRegistry: ModelRegistry; readonly resourceLoader: ResourceLoader; + readonly fallbackResolver: FallbackResolver | undefined; // Mutable session state accessed across modules _scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; diff --git a/packages/gsd-agent-core/src/session/agent-session-prompt.ts b/packages/gsd-agent-core/src/session/agent-session-prompt.ts index 3122ea12b2..bbcecbcab6 100644 --- a/packages/gsd-agent-core/src/session/agent-session-prompt.ts +++ b/packages/gsd-agent-core/src/session/agent-session-prompt.ts @@ -41,8 +41,13 @@ export class AgentSessionPromptModule { return false; } - if (this.isRetryableError(msg) && (await this.prepareRetry(msg))) { - return true; + if (this.isRetryableError(msg)) { + if (await this.tryFallback()) { + return true; + } + if (await this.prepareRetry(msg)) { + return true; + } } if (msg.stopReason === "error" && this.host._retryAttempt > 0) { @@ -485,7 +490,35 @@ export class AgentSessionPromptModule { // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, service unavailable, network/connection errors (including connection lost), WebSocket transport closes/errors, fetch failed, premature stream endings, HTTP/2 closed before response, terminated, retry delay exceeded return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|stream ended before message_stop|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test( err, - ); + ); + } + + async tryFallback(): Promise { + const resolver = this.host.fallbackResolver; + const currentModel = this.host.model; + if (!resolver || !currentModel) { + return false; + } + + const result = await resolver.findFallback(currentModel, "retryable"); + if (!result) { + return false; + } + + const messages = this.host.agent.state.messages; + if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { + this.host.agent.state.messages = messages.slice(0, -1); + } + this.host._retryAttempt = 0; + await this.host.setModel(result.model); + this.host.emit({ + type: "fallback_provider_switch", + fromProvider: currentModel.provider, + toProvider: result.model.provider, + model: result.model, + reason: result.reason, + }); + return true; } async prepareRetry(message: AssistantMessage): Promise { diff --git a/packages/gsd-agent-core/src/session/agent-session-types.ts b/packages/gsd-agent-core/src/session/agent-session-types.ts index f173eeea6e..6719d6d992 100644 --- a/packages/gsd-agent-core/src/session/agent-session-types.ts +++ b/packages/gsd-agent-core/src/session/agent-session-types.ts @@ -23,6 +23,7 @@ import type { SettingsManager } from "@gsd/pi-coding-agent/core/settings-manager import type { SourceInfo } from "@gsd/pi-coding-agent/core/source-info.js"; import type { SessionStartEvent } from "@gsd/pi-coding-agent/core/extensions/index.js"; import type { CompactionResult } from "../compaction/index.js"; +import type { FallbackResolver } from "../fallback-resolver.js"; // Skill Block Parsing // ============================================================================ @@ -153,6 +154,8 @@ export interface AgentSessionConfig { extensionRunnerRef?: { current?: ExtensionRunner }; /** Session start event metadata emitted when extensions bind to this runtime. */ sessionStartEvent?: SessionStartEvent; + /** Optional fallback resolver for switching models after provider errors. */ + fallbackResolver?: FallbackResolver; } export interface ExtensionBindings {