Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 63 additions & 28 deletions src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,49 @@ function createCustomModel(provider: string, modelId: string, baseUrl: string):
};
}

/**
* Resolve a "provider:model" string into a pi-ai Model, handling custom
* endpoints (`provider:model@base-url`), the GITAGENT_MODEL_BASE_URL override,
* and API-key normalization for non-built-in providers.
*
* Shared by the preferred model and every manifest fallback entry so all
* candidates resolve identically.
*/
export function resolveModel(modelStr: string): Model<any> {
const { provider, modelId } = parseModelString(modelStr);
const envBaseUrl = process.env.GITAGENT_MODEL_BASE_URL;

let model: Model<any>;
if (modelId.includes("@")) {
// Custom endpoint: provider:model-id@base-url
const atIndex = modelId.indexOf("@");
model = createCustomModel(provider, modelId.slice(0, atIndex), modelId.slice(atIndex + 1));
} else if (envBaseUrl) {
// Environment-specified base URL overrides all providers
model = createCustomModel(provider, modelId, envBaseUrl);
} else {
// Standard registered model
model = getModel(provider as any, modelId as any);
}

// For custom providers not in pi-ai's env key map, ensure an API key is available.
// pi-ai calls getEnvApiKey(model.provider) which only knows built-in providers.
// For unknown providers using openai-completions API, set provider to "openai" so
// pi-ai finds OPENAI_API_KEY. The actual auth happens via custom headers on the model.
const knownProviders = new Set(["openai", "anthropic", "google", "google-vertex", "groq", "cerebras", "xai", "openrouter", "mistral", "amazon-bedrock", "azure-openai-responses", "huggingface", "opencode", "kimi-coding", "github-copilot"]);
if (model.baseUrl && !knownProviders.has(provider)) {
// Use provider-specific key if available, otherwise use LYZR key or dummy
const providerKey = process.env[`${provider.toUpperCase()}_API_KEY`] || process.env.LYZR_API_KEY;
if (providerKey && !process.env.OPENAI_API_KEY) {
process.env.OPENAI_API_KEY = providerKey;
}
// Override provider to "openai" so pi-ai resolves the API key correctly
(model as any).provider = "openai";
}

return model;
}

async function ensureGitagentDir(agentDir: string): Promise<string> {
const gitagentDir = join(agentDir, ".gitagent");
await mkdir(gitagentDir, { recursive: true });
Expand Down Expand Up @@ -129,6 +172,9 @@ export interface LoadedAgent {
systemPrompt: string;
manifest: AgentManifest;
model: Model<any>;
/** Fallback models (resolved from manifest.model.fallback), tried in order
* when the primary fails with a retryable provider error. */
fallbackModels: Model<any>[];
skills: SkillMetadata[];
knowledge: LoadedKnowledge;
workflows: WorkflowMetadata[];
Expand Down Expand Up @@ -387,41 +433,30 @@ Do NOT track trivial single-command tasks (e.g. "what time is it"). But DO check
);
}

const { provider, modelId } = parseModelString(modelStr);
const envBaseUrl = process.env.GITAGENT_MODEL_BASE_URL;

let model: Model<any>;
if (modelId.includes("@")) {
// Custom endpoint: provider:model-id@base-url
const atIndex = modelId.indexOf("@");
model = createCustomModel(provider, modelId.slice(0, atIndex), modelId.slice(atIndex + 1));
} else if (envBaseUrl) {
// Environment-specified base URL overrides all providers
model = createCustomModel(provider, modelId, envBaseUrl);
} else {
// Standard registered model
model = getModel(provider as any, modelId as any);
}

// For custom providers not in pi-ai's env key map, ensure an API key is available.
// pi-ai calls getEnvApiKey(model.provider) which only knows built-in providers.
// For unknown providers using openai-completions API, set provider to "openai" so
// pi-ai finds OPENAI_API_KEY. The actual auth happens via custom headers on the model.
const knownProviders = new Set(["openai", "anthropic", "google", "google-vertex", "groq", "cerebras", "xai", "openrouter", "mistral", "amazon-bedrock", "azure-openai-responses", "huggingface", "opencode", "kimi-coding", "github-copilot"]);
if (model.baseUrl && !knownProviders.has(provider)) {
// Use provider-specific key if available, otherwise use LYZR key or dummy
const providerKey = process.env[`${provider.toUpperCase()}_API_KEY`] || process.env.LYZR_API_KEY;
if (providerKey && !process.env.OPENAI_API_KEY) {
process.env.OPENAI_API_KEY = providerKey;
const model = resolveModel(modelStr);

// Resolve fallback models declared in the manifest. Used at runtime when the
// primary provider fails with a retryable error (e.g. credit balance too low).
// Entries are trimmed and de-duplicated (including against the preferred
// model); blanks and unparseable entries are skipped rather than failing load.
const fallbackModels: Model<any>[] = [];
const seenModelStrings = new Set<string>([modelStr.trim()]);
for (const rawFb of manifest.model.fallback ?? []) {
const fb = (rawFb ?? "").trim();
if (!fb || seenModelStrings.has(fb)) continue;
seenModelStrings.add(fb);
try {
fallbackModels.push(resolveModel(fb));
Comment on lines +442 to +449
} catch {
// Skip unparseable fallback entries — they shouldn't break startup.
}
// Override provider to "openai" so pi-ai resolves the API key correctly
(model as any).provider = "openai";
}

return {
systemPrompt,
manifest,
model,
fallbackModels,
skills,
knowledge,
workflows,
Expand Down
53 changes: 53 additions & 0 deletions src/model-fallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Model fallback support.
*
* `manifest.model.fallback` lets an agent declare alternate models to use when
* the primary provider fails. Historically this list was parsed but never used
* at runtime — a single provider outage (e.g. Anthropic "credit balance too
* low") would kill the run even when a working provider was also configured
* (issue #24). This module decides which provider errors are worth retrying on
* a different model.
*/

// Signatures of provider-side failures where switching to a different model
// (usually a different provider/account) is likely to succeed. Deliberately
// conservative: we only retry on availability/billing/auth-class errors, never
// on errors caused by the request itself (bad input, content filter, etc.).
const RETRYABLE_PATTERNS: RegExp[] = [
/credit balance/i,
/insufficient (?:credit|quota|funds|balance)/i,
/quota/i,
/billing/i,
/payment/i,
/rate.?limit/i,
/\b429\b/,
/overloaded/i,
/\b529\b/,
/temporarily unavailable/i,
/service unavailable/i,
/\b503\b/,
/\b502\b/,
/\b500\b/,
/internal server error/i,
/timeout/i,
/timed out/i,
/econnreset|econnrefused|etimedout|enotfound/i,
/authentication|unauthorized|invalid api key|invalid x-api-key|permission/i,
/\b401\b/,
/\b403\b/,
];

/**
* Whether a provider error message indicates the request should be retried on
* the next fallback model.
*
* - Empty/missing message → retry: we have no detail, so give the next model a
* chance rather than dead-ending on an opaque failure.
* - Recognized availability/billing/auth pattern → retry.
* - Any other non-empty message (e.g. a malformed-request error) → do not
* retry: switching models won't fix a bad request.
*/
export function isRetryableProviderError(message: string | undefined | null): boolean {
if (!message) return true;
return RETRYABLE_PATTERNS.some((re) => re.test(message));
}
2 changes: 1 addition & 1 deletion src/sdk-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export interface GCToolResultMessage {
export interface GCSystemMessage {
type: "system";
subtype: "session_start" | "session_end" | "hook_blocked"
| "compliance_warning" | "error";
| "compliance_warning" | "error" | "fallback";
content: string;
metadata?: Record<string, any>;
}
Expand Down
Loading