diff --git a/src/core/ai/recipes/ollama.ts b/src/core/ai/recipes/ollama.ts index 361192f3f..1848391bc 100644 --- a/src/core/ai/recipes/ollama.ts +++ b/src/core/ai/recipes/ollama.ts @@ -33,6 +33,20 @@ export const ollama: Recipe = { // OLLAMA_NUM_PARALLEL config; no static cap to declare. v0.32 (#779). no_batch_cap: true, }, + chat: { + models: ['llama3.3', 'qwen3', 'deepseek-r1'], + supports_tools: true, + supports_subagent_loop: false, + supports_prompt_cache: false, + cost_per_1m_input_usd: 0, + cost_per_1m_output_usd: 0, + price_last_verified: '2026-06-02', + }, + expansion: { + models: ['llama3.3', 'qwen3', 'deepseek-r1'], + cost_per_1m_tokens_usd: 0, + price_last_verified: '2026-06-02', + }, }, setup_hint: 'Install Ollama from https://ollama.ai, then `ollama pull nomic-embed-text` and `ollama serve`.', }; diff --git a/src/core/brainstorm/judges.ts b/src/core/brainstorm/judges.ts index 00cdad468..44d26d13d 100644 --- a/src/core/brainstorm/judges.ts +++ b/src/core/brainstorm/judges.ts @@ -361,6 +361,11 @@ function isAxisScoreInRange(n: unknown): n is number { function validateIdeaShape(raw: unknown): { id: string; scores: JudgeAxisScores; note: string } | null { if (typeof raw !== 'object' || raw === null) return null; const r = raw as Record; + // Some models (e.g. Kimi K2) return numeric ids instead of string ids like "Idea 01". + // Coerce to string for compatibility. + if (typeof r.id === 'number') { + r.id = 'Idea ' + String(r.id).padStart(2, '0'); + } if (typeof r.id !== 'string') return null; const note = typeof r.note === 'string' ? r.note : ''; const s = r.scores; diff --git a/src/core/budget/budget-tracker.ts b/src/core/budget/budget-tracker.ts index d5bf44e21..b9f1b1b1d 100644 --- a/src/core/budget/budget-tracker.ts +++ b/src/core/budget/budget-tracker.ts @@ -155,6 +155,20 @@ const FREE_LOCAL_EMBED_PROVIDERS: ReadonlySet = new Set([ 'llama-server', ]); +/** + * Provider id prefixes whose chat models run on local inference (electricity, + * not API tokens) and so price at $0. Without this, a `--max-cost`-bounded + * brainstorm/lsd configured for a local chat provider TX2 hard-fails because + * lookupPricing has no entry for them. Matched against the provider half + * of the `provider:model` string. + * + * Sibling to FREE_LOCAL_EMBED_PROVIDERS and FREE_LOCAL_RERANK_PROVIDERS. + */ +const FREE_LOCAL_CHAT_PROVIDERS: ReadonlySet = new Set([ + 'ollama', + 'llama-server', +]); + /** * Look up `modelId` in the chat or embedding pricing maps. Returns a * per-1M-token price tuple, or null when unknown. @@ -201,6 +215,9 @@ function lookupPricing(modelId: string, kind: BudgetKind): ModelPricing | null { if (kind === 'rerank' && providerId && FREE_LOCAL_RERANK_PROVIDERS.has(providerId)) { return { input: 0, output: 0 }; } + if (kind === 'chat' && providerId && FREE_LOCAL_CHAT_PROVIDERS.has(providerId)) { + return { input: 0, output: 0 }; + } return null; } diff --git a/src/core/think/index.ts b/src/core/think/index.ts index f06ab59dc..78e5e517b 100644 --- a/src/core/think/index.ts +++ b/src/core/think/index.ts @@ -815,7 +815,7 @@ function buildGracefulMessage(modelStr: string): { type: 'message', role: 'assistant', model: modelStr, - content: [{ type: 'text', text: '(no LLM available — set anthropic_api_key via gbrain config or ANTHROPIC_API_KEY env)' }], + content: [{ type: 'text', text: '(no LLM available — set chat_model, expansion_model, or anthropic_api_key via gbrain config)' }], usage: { input_tokens: 0, output_tokens: 0 }, stop_reason: 'end_turn', };