diff --git a/src/ai-providers/agent-sdk/query.ts b/src/ai-providers/agent-sdk/query.ts index a77ec8ee..1a865808 100644 --- a/src/ai-providers/agent-sdk/query.ts +++ b/src/ai-providers/agent-sdk/query.ts @@ -85,6 +85,35 @@ function stripImageData(raw: string): string { } catch { return raw } } +// ==================== Error classification ==================== + +type ErrorClass = 'auth' | 'model' | 'unknown' + +const AUTH_PATTERNS = [ + /\b401\b/, + /\binvalid[_\s-]?api[_\s-]?key\b/i, + /\bauthentication\b/i, + /\bunauthor(?:ized|ised)\b/i, + /\bpermission[_\s-]denied\b/i, + /\bx-api-key\b/i, +] + +const MODEL_PATTERNS = [ + /\bmodel[_\s-]not[_\s-]found\b/i, + /\binvalid[_\s-]?model\b/i, + /\bunknown[_\s-]?model\b/i, + /\bmodel\s+.+?\s+(?:does\s+not\s+exist|is\s+not\s+(?:a\s+)?valid)\b/i, +] + +function classifyError(details: Record): ErrorClass { + const haystack = [details.message, details.stderr, details.stdout] + .filter((x): x is string => typeof x === 'string') + .join('\n') + if (AUTH_PATTERNS.some(p => p.test(haystack))) return 'auth' + if (MODEL_PATTERNS.some(p => p.test(haystack))) return 'model' + return 'unknown' +} + // ==================== Public ==================== /** @@ -217,8 +246,18 @@ export async function askAgentSdk( resultText = result.errors?.join('\n') ?? `Agent SDK error: ${result.subtype}` // Log failed results with all available detail const resultDetail = { subtype: result.subtype, errors: result.errors, result: result.result } - logger.error({ ...resultDetail, turns: result.num_turns, durationMs: result.duration_ms }, 'result_error') - console.error('[agent-sdk] Non-success result:', resultDetail) + const classification = classifyError({ + message: resultText, + stderr: Array.isArray(result.errors) ? result.errors.join('\n') : undefined, + }) + logger.error({ ...resultDetail, classification, turns: result.num_turns, durationMs: result.duration_ms }, 'result_error') + if (classification === 'auth') { + console.warn('[agent-sdk] Auth failed — check your API key / baseUrl in the active profile') + } else if (classification === 'model') { + console.warn('[agent-sdk] Model not available on this endpoint — check the region / model combo') + } else { + console.error('[agent-sdk] Non-success result:', resultDetail) + } } logger.info({ subtype: result.subtype, turns: result.num_turns, durationMs: result.duration_ms }, 'result') } @@ -238,9 +277,16 @@ export async function askAgentSdk( const extraKeys = Object.keys(errObj).filter(k => !(k in details)) for (const k of extraKeys) details[k] = (errObj as any)[k] - logger.error(details, 'query_error') - // Also log to console so the developer can see it in the terminal - console.error('[agent-sdk] Claude Code process error:', details) + const classification = classifyError(details) + logger.error({ ...details, classification }, 'query_error') + if (classification === 'auth') { + // User-fixable: don't scream, just hint. Full detail already in logs/agent-sdk.log. + console.warn('[agent-sdk] Auth failed — check your API key / baseUrl in the active profile') + } else if (classification === 'model') { + console.warn('[agent-sdk] Model not available on this endpoint — check the region / model combo') + } else { + console.error('[agent-sdk] Claude Code process error:', details) + } ok = false const stderrHint = details.stderr ? `\nstderr: ${details.stderr}` : '' resultText = `Agent SDK error: ${errObj.message}${stderrHint}` diff --git a/src/ai-providers/preset-catalog.ts b/src/ai-providers/preset-catalog.ts index 0f60a9fd..ef6343c4 100644 --- a/src/ai-providers/preset-catalog.ts +++ b/src/ai-providers/preset-catalog.ts @@ -20,6 +20,11 @@ export interface ModelOption { label: string } +export interface EndpointOption { + id: string + label: string +} + export interface PresetDef { id: string label: string @@ -29,6 +34,7 @@ export interface PresetDef { defaultName: string zodSchema: z.ZodType models?: ModelOption[] + endpoints?: EndpointOption[] writeOnlyFields?: string[] } @@ -143,20 +149,84 @@ export const MINIMAX: PresetDef = { description: 'MiniMax models via Claude Agent SDK (Anthropic-compatible)', category: 'third-party', defaultName: 'MiniMax', - hint: 'Get your API key at minimaxi.com', + hint: 'China console: minimaxi.com — International console: minimax.io. API keys are region-locked.', zodSchema: z.object({ backend: z.literal('agent-sdk'), loginMethod: z.literal('api-key'), - baseUrl: z.literal('https://api.minimaxi.com/anthropic').describe('MiniMax API endpoint'), + baseUrl: z.string().default('https://api.minimaxi.com/anthropic').describe('API endpoint'), model: z.string().default('MiniMax-M2.7').describe('Model'), apiKey: z.string().min(1).describe('MiniMax API key'), }), + endpoints: [ + { id: 'https://api.minimaxi.com/anthropic', label: 'China (minimaxi.com)' }, + { id: 'https://api.minimax.io/anthropic', label: 'International (minimax.io)' }, + ], models: [ { id: 'MiniMax-M2.7', label: 'MiniMax M2.7' }, ], writeOnlyFields: ['apiKey'], } +// ==================== Third-party: GLM (Zhipu) ==================== + +export const GLM: PresetDef = { + id: 'glm', + label: 'GLM (Zhipu)', + description: 'Zhipu GLM models via Claude Agent SDK (Anthropic-compatible)', + category: 'third-party', + defaultName: 'GLM', + hint: 'China console: bigmodel.cn — International console: z.ai. API keys are region-locked. Latest GLM 5.1 is China-only for now.', + zodSchema: z.object({ + backend: z.literal('agent-sdk'), + loginMethod: z.literal('api-key'), + baseUrl: z.string().default('https://open.bigmodel.cn/api/anthropic').describe('API endpoint'), + model: z.string().default('glm-4.7').describe('Model'), + apiKey: z.string().min(1).describe('GLM API key'), + }), + endpoints: [ + { id: 'https://open.bigmodel.cn/api/anthropic', label: 'China (bigmodel.cn)' }, + { id: 'https://api.z.ai/api/anthropic', label: 'International (z.ai)' }, + ], + models: [ + { id: 'glm-5.1', label: 'GLM 5.1 (China only)' }, + { id: 'glm-4.7', label: 'GLM 4.7' }, + { id: 'glm-4.6', label: 'GLM 4.6 — 200K (China only)' }, + { id: 'glm-4.5-air', label: 'GLM 4.5 Air' }, + ], + writeOnlyFields: ['apiKey'], +} + +// ==================== Third-party: Kimi (Moonshot) ==================== + +// Moonshot officially pushes OpenAI Chat Completions as the primary integration +// path; we route via their secondary Anthropic-compat endpoint +// (api.moonshot.*/anthropic) to stay on agent-sdk. Our codex backend speaks +// the OpenAI Responses API, which Moonshot's direct endpoints do not +// implement, so codex isn't a viable alternative here. +export const KIMI: PresetDef = { + id: 'kimi', + label: 'Kimi (Moonshot)', + description: 'Moonshot Kimi models via Claude Agent SDK (Anthropic-compatible)', + category: 'third-party', + defaultName: 'Kimi', + hint: 'China console: platform.moonshot.cn — International console: platform.moonshot.ai. API keys are region-locked.', + zodSchema: z.object({ + backend: z.literal('agent-sdk'), + loginMethod: z.literal('api-key'), + baseUrl: z.string().default('https://api.moonshot.cn/anthropic').describe('API endpoint'), + model: z.string().default('kimi-k2.5').describe('Model'), + apiKey: z.string().min(1).describe('Moonshot API key'), + }), + endpoints: [ + { id: 'https://api.moonshot.cn/anthropic', label: 'China (moonshot.cn)' }, + { id: 'https://api.moonshot.ai/anthropic', label: 'International (moonshot.ai)' }, + ], + models: [ + { id: 'kimi-k2.5', label: 'Kimi K2.5' }, + ], + writeOnlyFields: ['apiKey'], +} + // ==================== Custom ==================== export const CUSTOM: PresetDef = { @@ -185,5 +255,7 @@ export const PRESET_CATALOG: PresetDef[] = [ CODEX_API, GEMINI, MINIMAX, + GLM, + KIMI, CUSTOM, ] diff --git a/src/ai-providers/presets.ts b/src/ai-providers/presets.ts index ca106966..13575927 100644 --- a/src/ai-providers/presets.ts +++ b/src/ai-providers/presets.ts @@ -30,12 +30,17 @@ function buildJsonSchema(def: PresetDef): Record { const raw = z.toJSONSchema(def.zodSchema) as Record const props = (raw.properties ?? {}) as Record> - // Replace model enum with oneOf (labeled options) - const mf = 'model' - if (def.models?.length && props[mf]) { - const oneOf = def.models.map(m => ({ const: m.id, title: m.label })) - const { enum: _e, ...rest } = props[mf] - props[mf] = { ...rest, oneOf } + // Replace scalar string fields with labeled oneOf when a catalog is provided + const labeledFields: Array<[string, Array<{ id: string; label: string }> | undefined]> = [ + ['model', def.models], + ['baseUrl', def.endpoints], + ] + for (const [field, options] of labeledFields) { + if (options?.length && props[field]) { + const oneOf = options.map(o => ({ const: o.id, title: o.label })) + const { enum: _e, ...rest } = props[field] + props[field] = { ...rest, oneOf } + } } // Mark writeOnly fields