codeep 3.4.1 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  // responses (no TUI) suitable for streaming back via session/update.
5
5
  import { config, getCurrentProvider, getModelsForCurrentProvider, setProvider, setApiKey, isConfigured, listSessionsWithInfo, startNewSession, loadSession, saveSession, initializeAsProject, isManuallyInitializedProject, setProjectPermission, hasWritePermission, hasReadPermission, sessionNameProblem, sessionNameTaken, } from '../config/index.js';
6
6
  import { symlinkedCodeepNotice } from '../utils/projectPaths.js';
7
- import { getProviderList, getProvider } from '../config/providers.js';
7
+ import { getProviderList, getProvider, replacementModelFor } from '../config/providers.js';
8
8
  import { telemetryCommand } from '../commands/core/telemetry.js';
9
9
  import { keysyncCommand } from '../commands/core/keysync.js';
10
10
  import { getProjectContext } from '../utils/project.js';
@@ -1387,20 +1387,25 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
1387
1387
  saveSession(session.codeepSessionId, session.history, session.workspaceRoot);
1388
1388
  // If the checkpoint captured a different provider/model, switch back.
1389
1389
  // configOptionsChanged signals the client to refresh its dropdowns.
1390
+ // A checkpoint predates any later retirement, so its model goes through
1391
+ // the same map as a stored config (`gpt-6-astra` comes back as
1392
+ // `gpt-6-sol`), looked up on the provider actually active after the switch.
1390
1393
  let providerChanged = false;
1391
1394
  if (cp.provider && cp.provider !== getCurrentProvider().id) {
1392
1395
  setProvider(cp.provider);
1393
1396
  providerChanged = true;
1394
1397
  }
1395
- if (cp.model && cp.model !== config.get('model')) {
1396
- config.set('model', cp.model);
1398
+ const cpModel = cp.model && (replacementModelFor(config.get('provider'), cp.model) ?? cp.model);
1399
+ if (cpModel && cpModel !== config.get('model')) {
1400
+ config.set('model', cpModel);
1397
1401
  providerChanged = true;
1398
1402
  }
1403
+ const movedNote = cpModel !== cp.model ? ` (the checkpoint's \`${cp.model}\` is no longer offered)` : '';
1399
1404
  const lines = [
1400
1405
  `## Rewound to ${cp.name ? `**${cp.name}**` : `\`${cp.id}\``}`,
1401
1406
  '',
1402
1407
  `Restored ${cp.messages.length} message${cp.messages.length === 1 ? '' : 's'} (was ${replacedCount}).`,
1403
- cp.provider && cp.model ? `Provider: \`${cp.provider}\` · Model: \`${cp.model}\`` : '',
1408
+ cp.provider && cpModel ? `Provider: \`${cp.provider}\` · Model: \`${cpModel}\`${movedNote}` : '',
1404
1409
  '',
1405
1410
  buildRewindGitHint(cp),
1406
1411
  ].filter(Boolean);
@@ -79,4 +79,17 @@ export declare function exitCodeFromWaitResult(result: unknown): number | null;
79
79
  * Exported for unit testing (see server.command.test.ts).
80
80
  */
81
81
  export declare function executeAcpCommand(command: string, args: string[], cwd: string, ctx: AcpCommandContext): Promise<AcpCommandOutcome>;
82
+ /**
83
+ * What to tell the user when a prompt fails on authentication, or null when
84
+ * the failure is about something else.
85
+ *
86
+ * Every 401 used to read "No API key configured", key or no key. Kimi Code
87
+ * answers 401 for its plan limits — no K3 on the plan, K3 past 256K on
88
+ * Plus/Moderato, High-Speed below Pro/Allegretto, an unknown model id
89
+ * (kimi.com/code/docs/en/kimi-code/error-reference.html) — so a subscriber with
90
+ * a perfectly good key was sent to /login when the fix was another model. With
91
+ * a key configured, a 401 now says what else it can mean and quotes the
92
+ * provider.
93
+ */
94
+ export declare function authFailureNotice(err: Error, providerId: string, keyConfigured: boolean): string | null;
82
95
  export declare function startAcpServer(transport?: StdioTransport): Promise<void>;
@@ -728,6 +728,43 @@ function persistSessionHistory(session) {
728
728
  return;
729
729
  saveSession(session.codeepSessionId, session.history, session.workspaceRoot);
730
730
  }
731
+ /** The provider's own words from an `API error: 401 - …` message, if any. */
732
+ function providerErrorDetail(message) {
733
+ const body = message.replace(/^[\s\S]*?\b401\s*-\s*/, '').trim();
734
+ let detail = body;
735
+ try {
736
+ const json = JSON.parse(body);
737
+ detail = json?.error?.message ?? json?.message ?? body;
738
+ }
739
+ catch {
740
+ // Not JSON — the text is the detail.
741
+ }
742
+ const text = String(detail).trim();
743
+ return text.length > 300 ? `${text.slice(0, 300)}…` : text;
744
+ }
745
+ /**
746
+ * What to tell the user when a prompt fails on authentication, or null when
747
+ * the failure is about something else.
748
+ *
749
+ * Every 401 used to read "No API key configured", key or no key. Kimi Code
750
+ * answers 401 for its plan limits — no K3 on the plan, K3 past 256K on
751
+ * Plus/Moderato, High-Speed below Pro/Allegretto, an unknown model id
752
+ * (kimi.com/code/docs/en/kimi-code/error-reference.html) — so a subscriber with
753
+ * a perfectly good key was sent to /login when the fix was another model. With
754
+ * a key configured, a 401 now says what else it can mean and quotes the
755
+ * provider.
756
+ */
757
+ export function authFailureNotice(err, providerId, keyConfigured) {
758
+ const is401 = err instanceof ApiError && err.status === 401;
759
+ if (!is401 && !err.message?.includes('API key'))
760
+ return null;
761
+ if (is401 && keyConfigured) {
762
+ const name = PROVIDERS[providerId]?.name ?? providerId;
763
+ const detail = providerErrorDetail(err.message ?? '');
764
+ return `❌ ${name} refused the request (401)${detail ? `: ${detail}` : ''}. A key is configured, so this is not a missing key: the key may be invalid or revoked, or your plan may not include this model or limit (Kimi Code, for one, answers 401 when a plan lacks K3, K3's 1M context or High-Speed). Pick another model with /model, or re-enter the key with /login.`;
765
+ }
766
+ return `❌ No API key configured. Use /login <provider> <key> or set the environment variable (e.g. ZAI_API_KEY, ANTHROPIC_API_KEY).`;
767
+ }
731
768
  export function startAcpServer(transport = new StdioTransport()) {
732
769
  // ACP sessionId → full AcpSession (includes history + codeep session tracking)
733
770
  const sessions = new Map();
@@ -1367,6 +1404,11 @@ export function startAcpServer(transport = new StdioTransport()) {
1367
1404
  },
1368
1405
  });
1369
1406
  };
1407
+ // Read when the prompt fails, not now: /login during the turn counts.
1408
+ const authNotice = (err) => {
1409
+ const providerId = config.get('provider');
1410
+ return authFailureNotice(err, providerId, Boolean(getApiKey(providerId)));
1411
+ };
1370
1412
  // Ask the user through the client. A person answers this: wait as long
1371
1413
  // as the dialog is open. Only cancelling the prompt stops the wait. No
1372
1414
  // answer (error, cancelled prompt, a reply without an outcome) is null,
@@ -1674,8 +1716,8 @@ export function startAcpServer(transport = new StdioTransport()) {
1674
1716
  }
1675
1717
  transport.respond(msg.id, { stopReason: 'cancelled' });
1676
1718
  }
1677
- else if (err.message?.includes('API key not configured') || err.message?.includes('API key') || (err instanceof ApiError && err.status === 401)) {
1678
- sendChunk(`❌ No API key configured. Use /login <provider> <key> or set the environment variable (e.g. ZAI_API_KEY, ANTHROPIC_API_KEY).`);
1719
+ else if (authNotice(err)) {
1720
+ sendChunk(authNotice(err));
1679
1721
  transport.respond(msg.id, { stopReason: 'end_turn' });
1680
1722
  }
1681
1723
  else if (err instanceof ApiError && err.status >= 500) {
@@ -1696,8 +1738,8 @@ export function startAcpServer(transport = new StdioTransport()) {
1696
1738
  if (err.name === 'AbortError' || abortController.signal.aborted) {
1697
1739
  transport.respond(msg.id, { stopReason: 'cancelled' });
1698
1740
  }
1699
- else if (err.message?.includes('API key not configured') || err.message?.includes('API key') || (err instanceof ApiError && err.status === 401)) {
1700
- sendChunk(`❌ No API key configured. Use /login <provider> <key> or set the environment variable (e.g. ZAI_API_KEY, ANTHROPIC_API_KEY).`);
1741
+ else if (authNotice(err)) {
1742
+ sendChunk(authNotice(err));
1701
1743
  transport.respond(msg.id, { stopReason: 'end_turn' });
1702
1744
  }
1703
1745
  else if (err instanceof ApiError && err.status >= 500) {
@@ -17,6 +17,7 @@
17
17
  // is "look up the session, mutate config or session state, acknowledge".
18
18
  import { AGENT_MODES, buildConfigOptions } from './server.js';
19
19
  import { config, setProvider, setApiKey, listSessionsWithInfo, deleteSession as deleteSessionFile, } from '../config/index.js';
20
+ import { replacementModelFor } from '../config/providers.js';
20
21
  import { disposeSession as disposeMcpSession } from '../utils/mcpRegistry.js';
21
22
  import { clearPendingPlan } from '../utils/planMode.js';
22
23
  // ─── session/set_mode ─────────────────────────────────────────────────────────
@@ -99,17 +100,16 @@ export function handleSetConfigOption(msg, deps) {
99
100
  */
100
101
  export function applyConfigOption(configId, value) {
101
102
  if (configId === 'model' && typeof value === 'string') {
102
- // value is "providerId/modelId" — split and switch both
103
+ // value is "providerId/modelId" — split and switch both. An editor setting
104
+ // pinned before a retirement (`openai/gpt-6-astra`) names an id the picker
105
+ // no longer has, so the model goes through the same map as a stored config,
106
+ // looked up on the provider actually active: setProvider refuses an unknown
107
+ // id and leaves the old one in place.
103
108
  const slashIdx = value.indexOf('/');
104
- if (slashIdx !== -1) {
105
- const providerId = value.slice(0, slashIdx);
106
- const modelId = value.slice(slashIdx + 1);
107
- setProvider(providerId); // sets provider + defaultModel + protocol
108
- config.set('model', modelId);
109
- }
110
- else {
111
- config.set('model', value);
112
- }
109
+ const modelId = slashIdx !== -1 ? value.slice(slashIdx + 1) : value;
110
+ if (slashIdx !== -1)
111
+ setProvider(value.slice(0, slashIdx)); // sets provider + defaultModel + protocol
112
+ config.set('model', replacementModelFor(config.get('provider'), modelId) ?? modelId);
113
113
  }
114
114
  else if (configId === 'provider' && typeof value === 'string') {
115
115
  // Switch provider without specifying a model — picks the provider's
package/dist/api/index.js CHANGED
@@ -3,7 +3,7 @@ import * as https from 'node:https';
3
3
  import { config, getApiKey, resolveBaseUrl, describeUnsendableKey } from '../config/index.js';
4
4
  import { withRetry, isNetworkError } from '../utils/retry.js';
5
5
  import { checkApiRateLimit } from '../utils/ratelimit.js';
6
- import { getProvider, getProviderBaseUrl, getProviderAuthHeader, usesMaxCompletionTokens, requiresDefaultTemperature, modelRejectsSamplingParams, reasoningParamsFor } from '../config/providers.js';
6
+ import { getProvider, getProviderBaseUrl, getProviderAuthHeader, usesMaxCompletionTokens, requiresDefaultTemperature, modelRejectsSamplingParams, reasoningParamsFor, minResponseTokensFor } from '../config/providers.js';
7
7
  import { logApiRequest, logApiResponse } from '../utils/logger.js';
8
8
  import { loadProjectIntelligence, generateContextFromIntelligence } from '../utils/projectIntelligence.js';
9
9
  import { loadProjectRules } from '../utils/agent.js';
@@ -366,7 +366,8 @@ async function chatOpenAI(message, history, model, apiKey, onChunk, abortSignal)
366
366
  const stream = Boolean(onChunk);
367
367
  const timeout = config.get('apiTimeout');
368
368
  const temperature = config.get('temperature');
369
- const maxTokens = config.get('maxTokens');
369
+ // Never below the model's floor (Opus 5.5 on OpenRouter — see minResponseTokensFor).
370
+ const maxTokens = Math.max(config.get('maxTokens'), minResponseTokensFor(model, config.get('reasoningEffort')));
370
371
  // Get provider-specific URL and auth. resolveBaseUrl applies user
371
372
  // overrides: Ollama (ollamaUrl), Custom (customBaseUrl), and OpenAI
372
373
  // (OPENAI_BASE_URL env) — so self-hosted / OpenAI-compatible endpoints work.
@@ -647,7 +648,9 @@ async function chatAnthropic(message, history, model, apiKey, onChunk, abortSign
647
648
  const stream = Boolean(onChunk);
648
649
  const timeout = config.get('apiTimeout');
649
650
  const temperature = config.get('temperature');
650
- const maxTokens = config.get('maxTokens');
651
+ // Never below the model's floor: Opus 5.5's always-on thinking spends the
652
+ // same limit as the answer (see minResponseTokensFor).
653
+ const maxTokens = Math.max(config.get('maxTokens'), minResponseTokensFor(model, config.get('reasoningEffort')));
651
654
  const baseUrl = getProviderBaseUrl(providerId, 'anthropic');
652
655
  const authHeader = getProviderAuthHeader(providerId, 'anthropic');
653
656
  if (!baseUrl) {
@@ -280,10 +280,18 @@ if (currentMigrationVersion < 1) {
280
280
  config.set('rateLimitCommands', 10000);
281
281
  }
282
282
  }
283
- if (currentMigrationVersion < 4) {
284
- // Vendor aliases below were removed from Codeep's curated catalogue after
285
- // their replacements became available. Migrate only exact known aliases:
286
- // OpenRouter/Ollama/custom model ids remain user-controlled.
283
+ // Retired model ids: on EVERY load, not once. This sat inside
284
+ // `currentMigrationVersion < 4` until 2026-09-23, and MIGRATION_VERSION had
285
+ // been 4 since 2026-08-15 — so every RETIRED_MODEL_REPLACEMENTS entry added
286
+ // after that (the GPT-5.5/5.4, Grok, DeepSeek and Gemini ones) reached only
287
+ // fresh configs and profile loads, never an existing user's active model. The
288
+ // lookup is exact and idempotent, it touches only ids a vendor retired or
289
+ // rerouted (never OpenRouter/Ollama/custom ids, which the map does not key),
290
+ // and it is what applyProfile and the macOS app already do on every load. The
291
+ // one-shot rule above protects settings a user chooses; the ids in that map are
292
+ // ones the vendor retired or reroutes, or that cannot work from Codeep (GPT-6
293
+ // Astra's tool calls), so rewriting them again cannot undo a working choice.
294
+ {
287
295
  const provider = config.get('provider');
288
296
  const model = config.get('model');
289
297
  const replacement = replacementModelFor(provider, model);
@@ -49,7 +49,14 @@ export interface ProviderConfig {
49
49
  export declare const PROVIDERS: Record<string, ProviderConfig>;
50
50
  export type ProviderId = keyof typeof PROVIDERS;
51
51
  export declare function getProvider(id: string): ProviderConfig | null;
52
+ /**
53
+ * One exact lookup, never followed further: every target must already be a
54
+ * model its own provider offers (providers.test.ts holds the map to that), so a
55
+ * chain can never be needed and the macOS mirror stays a flat table too.
56
+ */
52
57
  export declare function replacementModelFor(providerId: string, modelId: string): string | undefined;
58
+ /** The whole map, read-only — for the invariant test and nothing else. */
59
+ export declare function retiredModelReplacements(): Readonly<Record<string, Readonly<Record<string, string>>>>;
53
60
  export declare function getProviderList(): {
54
61
  id: string;
55
62
  name: string;
@@ -94,6 +101,21 @@ export declare function modelRejectsSamplingParams(model: string): boolean;
94
101
  * Falls back to the requested value if no provider limit is set.
95
102
  */
96
103
  export declare function getEffectiveMaxTokens(providerId: string, requested: number): number;
104
+ /**
105
+ * The smallest response budget (`max_tokens`) worth sending this model, or 0
106
+ * for no floor. Callers take the larger of this and whatever they would have
107
+ * sent, then apply getEffectiveMaxTokens as usual.
108
+ *
109
+ * Claude Opus 5.5 thinks on every request — adaptive thinking cannot be turned
110
+ * off — and "tends to think more per turn than Claude Opus 5" at the same
111
+ * effort; Anthropic's notes say to "leave room in max_tokens for the thinking",
112
+ * which spends the same limit as the answer. The task planner's 2048, or a
113
+ * maxTokens lowered in /settings, could go on thinking alone and cut the reply
114
+ * off. 32K is Codeep's own default; the Max tier gets 64K, where Anthropic's
115
+ * advice for max effort on Opus 5 is "starting at 64k tokens". Matched on the
116
+ * canonical id, so `anthropic/claude-opus-5.5` on OpenRouter is covered too.
117
+ */
118
+ export declare function minResponseTokensFor(model: string, tier: ReasoningTier | undefined): number;
97
119
  /**
98
120
  * Unified, user-facing thinking-effort tiers (the `/thinking` setting).
99
121
  *
@@ -102,7 +124,7 @@ export declare function getEffectiveMaxTokens(providerId: string, requested: num
102
124
  *
103
125
  * The four tiers are CONCEPTUAL. `reasoningParamsFor()` clamps each one to the
104
126
  * nearest level the active provider+model actually accepts, so we never send a
105
- * value that would 400 (e.g. Gemini rejects "medium"; OpenAI has no "max").
127
+ * value that would 400 (e.g. Gemini has no "max"; GPT-5.5 tops out at "xhigh").
106
128
  * The control is a pure DEPTH knob on models that already think — it never
107
129
  * toggles thinking on/off, which keeps us clear of the reasoning_content-replay
108
130
  * contract that DeepSeek/GLM impose when thinking mode is flipped.
@@ -116,6 +138,21 @@ export declare const REASONING_TIERS: ReasoningTier[];
116
138
  * `claude-opus-4.8` → `claude-opus-4-8`). Mirrors macOS `ModelTuning.canonicalModelID`.
117
139
  */
118
140
  export declare function canonicalModelId(model: string): string;
141
+ /**
142
+ * GPT-6 Sol and Luna on Chat Completions support function calling "only with
143
+ * `reasoning_effort` set to `none`" (developers.openai.com models/gpt-6-sol,
144
+ * guides/latest-model). So a request that carries tools to them must send
145
+ * "none", whatever /thinking says. Direct `openai` only: OpenRouter may reach
146
+ * OpenAI through the Responses API, where the rule does not apply, and Astra
147
+ * rejects "none" with a 400 (it is not offered on `openai` at all).
148
+ */
149
+ export declare function toolsForceReasoningOff(providerId: string, model: string): boolean;
150
+ /**
151
+ * What /thinking must tell the user when the tier does not reach every request
152
+ * for this model, or null. Without it the setting would look applied while
153
+ * agent turns quietly ran at "none".
154
+ */
155
+ export declare function agentTurnReasoningNote(providerId: string, model: string): string | null;
119
156
  /**
120
157
  * Does this provider+model expose a GRADED thinking-effort control we can drive?
121
158
  * Used to gate the `/thinking` UI — hidden entirely for models without one.
@@ -128,12 +165,19 @@ export declare function modelSupportsReasoningEffort(providerId: string, model:
128
165
  * models, or providers without a graded knob — so callers can spread it
129
166
  * unconditionally. Keep in lockstep with macOS `ModelTuning.reasoningParams`.
130
167
  */
131
- export declare function reasoningParamsFor(providerId: string, model: string, tier: ReasoningTier): Record<string, unknown>;
168
+ export declare function reasoningParamsFor(providerId: string, model: string, tier: ReasoningTier,
169
+ /** The request carries a non-empty `tools` array (native tool calling). */
170
+ opts?: {
171
+ tools?: boolean;
172
+ }): Record<string, unknown>;
132
173
  /**
133
174
  * The DISTINCT tiers a given provider+model actually exposes — used to build a
134
175
  * per-model picker that only offers levels the model can tell apart (e.g.
135
- * GLM-5.2/DeepSeek grade only high|max; Gemini via the OpenAI-compat layer only
136
- * low|high). Always leads with 'auto'. `[]` for models with no graded knob.
176
+ * GLM-5.2 grades only high|max; Gemini via the OpenAI-compat layer has no max).
177
+ * Always leads with 'auto'. `[]` for models with no graded knob.
178
+ *
179
+ * GPT-6 Sol/Luna list their full set: it is what they run on plain chat. Agent
180
+ * turns send "none" regardless (toolsForceReasoningOff), and /thinking says so.
137
181
  *
138
182
  * Kept in lockstep with `reasoningParamsFor` (the providers-test asserts every
139
183
  * listed tier yields a DISTINCT param, so this can't silently drift). Mirrors