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
3 changes: 2 additions & 1 deletion packages/ai/src/providers/openai-completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,8 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio
maybeAddOpenRouterAnthropicCacheControl(model, messages);

const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model: model.id,
// Context-tiered models (e.g. Kimi K3) send a cheaper variant id on the wire.
model: model.wireModelId ?? model.id,
messages,
stream: true,
};
Expand Down
7 changes: 7 additions & 0 deletions packages/ai/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,13 @@ export interface Model<TApi extends Api> {
contextWindow: number;
maxTokens: number;
headers?: Record<string, string>;
/**
* Model identifier sent to the API when it differs from the registry id.
* Used by context-tiered models such as Kimi K3, where dreb exposes a
* single user-facing model (`k3`) but sends the cheaper 256k variant
* (`k3-256k`) on the wire until the session context outgrows it.
*/
wireModelId?: string;
/** Explicit auth transport. When unset, the provider's default and token heuristics apply. */
authMode?: AuthMode;
/** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */
Expand Down
5 changes: 4 additions & 1 deletion packages/ai/src/utils/oauth/kimi-coding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -811,8 +811,11 @@ export const kimiCodingOAuthProvider: OAuthProviderInterface = {

// The official client treats only the explicit "anthropic" protocol as a
// separate wire format; absent and future values use the default Kimi route.
// `k3-256k` is the cheaper 256k wire variant of `k3`; dreb surfaces both
// through the single user-facing `k3` model (auto context tier), so it
// must not appear as a separate selectable model.
const supportedDiscovered = discovered.filter(
(info) => info.supports_tool_use !== false && info.protocol !== "anthropic",
(info) => info.supports_tool_use !== false && info.protocol !== "anthropic" && info.id !== "k3-256k",
);
const result: Model<Api>[] = [];
const seen = new Set<string>();
Expand Down
17 changes: 17 additions & 0 deletions packages/ai/test/kimi-coding-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1515,6 +1515,23 @@ describe("Kimi For Coding OAuth", () => {
expect(result.filter((m) => m.provider === "kimi-coding-oauth").map((m) => m.id)).toEqual(["k3"]);
});

it("never surfaces the k3-256k wire variant as a separate selectable model", () => {
const creds: OAuthCredentials & { models?: KimiModelInfo[] } = {
refresh: "r",
access: "a",
expires: Date.now() + 120_000,
models: [
{ id: "k3", display_name: "K3", context_length: 1048576 },
{ id: "k3-256k", display_name: "K3 256k", context_length: 262144 },
],
};

const result = kimiCodingOAuthProvider.modifyModels!(buildStaticModels(), creds);
const ids = result.filter((m) => m.provider === "kimi-coding-oauth").map((m) => m.id);

expect(ids).toEqual(["k3"]);
});

it("preserves static IDs and appends an unknown legacy model", () => {
const creds: OAuthCredentials & {
modelId?: string;
Expand Down
42 changes: 42 additions & 0 deletions packages/ai/test/openai-completions-kimi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,3 +452,45 @@ describe("openai-completions kimi thinkingFormat", () => {
]);
});
});

describe("openai-completions wireModelId", () => {
beforeEach(() => {
mockState.lastParams = undefined;
mockState.chunks = undefined;
});

it("sends wireModelId on the wire while recording the registry model id", async () => {
const model: Model<"openai-completions"> = {
...KIMI_MODEL,
provider: "kimi-coding-oauth",
id: "k3",
wireModelId: "k3-256k",
contextWindow: 262144,
};

const result = await streamSimple(
model,
{ messages: [{ role: "user", content: "Hi", timestamp: Date.now() }] },
{ apiKey: "test" },
).result();

expect((mockState.lastParams as { model?: string }).model).toBe("k3-256k");
expect(result.model).toBe("k3");
});

it("sends the registry model id when wireModelId is unset", async () => {
const model: Model<"openai-completions"> = {
...KIMI_MODEL,
provider: "kimi-coding-oauth",
id: "k3",
};

await streamSimple(
model,
{ messages: [{ role: "user", content: "Hi", timestamp: Date.now() }] },
{ apiKey: "test" },
).result();

expect((mockState.lastParams as { model?: string }).model).toBe("k3");
});
});
1 change: 1 addition & 0 deletions packages/coding-agent/docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Use `/logout` to clear credentials. Tokens are stored in `~/.dreb/agent/auth.jso
- `/login` uses the Kimi Code OAuth subscription endpoint at `https://api.kimi.com/coding/v1`.
- `KIMI_API_KEY` uses Kimi For Coding's Anthropic-compatible API at `https://api.kimi.com/coding`.
- Built-in OAuth models are `kimi-for-coding` (default, 262k context), `k3` (1M context), and `kimi-for-coding-highspeed` (262k context). Model availability is plan-dependent: the `k3` 1M-context ID and the `kimi-for-coding-highspeed` ID are only exposed when the subscription includes them, and `kimi-for-coding-highspeed` runs at roughly 6× speed for 3× quota usage. On login/refresh, dreb asks the Kimi API which models the subscription is entitled to and updates context, reasoning, image, tool-use, protocol, and thinking-effort metadata. Compatible newly discovered IDs are templated conservatively; if discovery fails, the static list remains available.
- The OAuth `k3` model is context-tiered: the Kimi endpoint serves it as `k3-256k` (256k context, cheaper) and `k3` (1M context, stated to consume 2× the quota), and upgrading does not invalidate the prompt cache. The cheaper variant is exclusive to the Kimi for Coding OAuth endpoint — the pay-per-token Moonshot AI Platform does not expose it. dreb starts every session on the `k3-256k` wire ID and automatically upgrades to `k3` once the session context passes the 256k cutoff (256k minus the default compaction reserve) instead of compacting. A user-lowered compaction threshold compacts before the cutoff and thus effectively disables the upgrade. Customizing `k3`'s context window via `models.json` disables automatic tiering entirely, and compacted or fresh sessions return to the cheaper tier.
- OAuth requests use the current Kimi Code device identity contract and share its stable `~/.kimi-code/device_id`. Extra low-precedence headers can be supplied with newline-separated `KIMI_CODE_CUSTOM_HEADERS` values.
- The OAuth coding endpoint accepts OpenAI-style multimodal content arrays with base64 `image_url` data URLs. dreb keeps a conservative 32k output-token cap because the managed model catalog does not currently advertise a per-model output limit.
- Moonshot Open Platform uses a different base URL (`https://api.moonshot.ai/v1`); don't assume both routes expose identical behavior.
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/docs/rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -1598,6 +1598,7 @@ Response:
| `length_retry` | Response hit the token limit; retrying with a larger budget |
| `auto_compaction_start` | Auto-compaction begins |
| `auto_compaction_end` | Auto-compaction completes |
| `context_window_upgrade` | Wire model tier auto-upgraded (e.g. Kimi K3 256k → 1M); includes `provider`, `modelId`, `fromContextWindow`, `toContextWindow` |
| `auto_retry_start` | Auto-retry begins (after transient error) |
| `auto_retry_end` | Auto-retry completes (success or final failure) |
| `background_agent_start` | Background subagent launched (includes `sessionDir`) |
Expand Down
126 changes: 114 additions & 12 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ import {
} from "./extensions/index.js";
import { checkScriptContent, extractScriptPaths, isForbiddenCommand } from "./forbidden-commands.js";
import { type GitRepoState, getGitRepoState } from "./git-repo-state.js";
import {
deriveK3ContextTierModel,
isK3256kTier,
K3_1M_CONTEXT_WINDOW,
K3_256K_CONTEXT_WINDOW,
K3_UPGRADE_CUTOFF_TOKENS,
shouldUpgradeK3Tier,
} from "./k3-context-tier.js";
import { log } from "./logger.js";
import type { BashExecutionMessage, CustomMessage } from "./messages.js";
import type { ModelRegistry } from "./model-registry.js";
Expand Down Expand Up @@ -152,7 +160,15 @@ export type AgentSessionEvent =
| { type: "parent_paused_for_background_agents"; runningAgentCount: number; turnsUsed: number; turnLimit: number }
| { type: "session_name_changed"; name: string }
| { type: "tasks_update"; tasks: readonly SessionTask[] }
| { type: "suggest_next"; command: string };
| { type: "suggest_next"; command: string }
| {
/** The wire model tier auto-upgraded because the session context outgrew the smaller tier (e.g. Kimi K3 256k → 1M). */
type: "context_window_upgrade";
provider: string;
modelId: string;
fromContextWindow: number;
toContextWindow: number;
};

/** Listener function for agent session events */
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
Expand Down Expand Up @@ -2017,7 +2033,7 @@ export class AgentSession {

const previousModel = this.model;
const thinkingLevel = this._getThinkingLevelForModelSwitch();
this.agent.setModel(model);
this.agent.setModel(this._applyContextTier(model));
this._refreshThinkingDisplay(model);
this.sessionManager.appendModelChange(model.provider, model.id);
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
Expand Down Expand Up @@ -2092,7 +2108,7 @@ export class AgentSession {
const thinkingLevel = this._getThinkingLevelForModelSwitch(next.thinkingLevel);

// Apply model
this.agent.setModel(next.model);
this.agent.setModel(this._applyContextTier(next.model));
this._refreshThinkingDisplay(next.model);
this.sessionManager.appendModelChange(next.model.provider, next.model.id);
this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);
Expand Down Expand Up @@ -2128,7 +2144,7 @@ export class AgentSession {
}

const thinkingLevel = this._getThinkingLevelForModelSwitch();
this.agent.setModel(nextModel);
this.agent.setModel(this._applyContextTier(nextModel));
this._refreshThinkingDisplay(nextModel);
this.sessionManager.appendModelChange(nextModel.provider, nextModel.id);
this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);
Expand Down Expand Up @@ -2351,6 +2367,11 @@ export class AgentSession {
const newEntries = this.sessionManager.getEntries();
const sessionContext = this.sessionManager.buildSessionContext();
this.agent.replaceMessages(sessionContext.messages);
// Re-derive the K3 context tier: the compacted context is small again,
// so the session returns to the cheaper 256k wire tier.
if (this.model) {
this.agent.setModel(this._applyContextTier(this.model));
}

// Get the saved compaction entry for the extension event
const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as
Expand Down Expand Up @@ -2405,12 +2426,11 @@ export class AgentSession {
*/
private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<void> {
const settings = this.settingsManager.getCompactionSettings();
if (!settings.enabled) return;

// Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return;

const contextWindow = this.model?.contextWindow ?? 0;
let contextWindow = this.model?.contextWindow ?? 0;

// Skip overflow check if the message came from a different model.
// This handles the case where user switched from a smaller-context model (e.g. opus)
Expand All @@ -2431,6 +2451,26 @@ export class AgentSession {

// Case 1: Overflow - LLM returned context overflow error
if (sameModel && isContextOverflow(assistantMessage, contextWindow)) {
// K3 auto context tier: an overflow in the 256k tier upgrades to the
// 1M tier instead of compacting — the Kimi backend grows the prompt
// cache seamlessly. This runs even when compaction is disabled since
// no context reduction is involved.
if (this._tryUpgradeK3ContextTier()) {
// Remove the error message from agent state (it IS saved to session
// for history, but we don't want it in context for the retry)
this._removeLastAssistantMessage();
setTimeout(() => {
this.agent.continue().catch((err) => {
this.warnInSession(
`Agent failed to continue after context window upgrade: ${err instanceof Error ? err.message : String(err)}`,
);
});
}, 100);
return;
}

if (!settings.enabled) return;

if (this._overflowRecoveryAttempted) {
this._emit({
type: "auto_compaction_end",
Expand All @@ -2446,10 +2486,7 @@ export class AgentSession {
this._overflowRecoveryAttempted = true;
// Remove the error message from agent state (it IS saved to session for history,
// but we don't want it in context for the retry)
const messages = this.agent.state.messages;
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
this.agent.replaceMessages(messages.slice(0, -1));
}
this._removeLastAssistantMessage();
await this._runAutoCompaction("overflow", true);
return;
}
Expand Down Expand Up @@ -2477,11 +2514,71 @@ export class AgentSession {
} else {
contextTokens = calculateContextTokens(assistantMessage.usage);
}

// K3 auto context tier: reaching the 256k cutoff upgrades to the 1M tier
// instead of compacting. This is model-capability management, not context
// reduction, so it applies even when compaction is disabled. The cutoff is
// fixed at the default compaction threshold of the 256k window; a
// user-lowered compaction threshold takes precedence and effectively
// disables the upgrade.
if (shouldUpgradeK3Tier(this.model, contextTokens)) {
// A user-lowered compaction threshold takes precedence over the
// upgrade: if the user's compact point for the 256k window sits below
// the default cutoff and is already exceeded, compact instead.
const userCompactPoint = K3_256K_CONTEXT_WINDOW - settings.reserveTokens;
const userThresholdPreempts =
settings.enabled && userCompactPoint < K3_UPGRADE_CUTOFF_TOKENS && contextTokens > userCompactPoint;
if (!userThresholdPreempts) {
this._tryUpgradeK3ContextTier();
contextWindow = this.model?.contextWindow ?? contextWindow;
}
}

if (!settings.enabled) return;

if (shouldCompact(contextTokens, contextWindow, settings)) {
await this._runAutoCompaction("threshold", false);
}
}

/**
* Apply the K3 auto context tier to a model being set on the agent. The
* user-facing `k3` model runs on the cheaper `k3-256k` wire model ID until
* the session context grows past the 256k cutoff; no-op for other models.
* See k3-context-tier.ts.
*/
private _applyContextTier(model: Model<any>): Model<any> {
return deriveK3ContextTierModel(model, estimateContextTokens(this.agent.state.messages).tokens);
}

/** Remove the last message from agent state when it is an assistant message. */
private _removeLastAssistantMessage(): void {
const messages = this.agent.state.messages;
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
this.agent.replaceMessages(messages.slice(0, -1));
}
}

/**
* Upgrade the K3 auto context tier from 256k to 1M. The Kimi backend
* upgrades the prompt cache seamlessly, so no context is lost or
* compacted. Returns true when the upgrade was applied.
*/
private _tryUpgradeK3ContextTier(): boolean {
const model = this.model;
if (!model || !isK3256kTier(model)) return false;
const upgraded = deriveK3ContextTierModel(model, K3_UPGRADE_CUTOFF_TOKENS + 1);
this.agent.setModel(upgraded);
this._emit({
type: "context_window_upgrade",
provider: upgraded.provider,
modelId: upgraded.id,
fromContextWindow: K3_256K_CONTEXT_WINDOW,
toContextWindow: K3_1M_CONTEXT_WINDOW,
});
return true;
}

/**
* Internal: Run auto-compaction with events.
*/
Expand Down Expand Up @@ -2569,6 +2666,11 @@ export class AgentSession {
const newEntries = this.sessionManager.getEntries();
const sessionContext = this.sessionManager.buildSessionContext();
this.agent.replaceMessages(sessionContext.messages);
// Re-derive the K3 context tier: the compacted context is small again,
// so the session returns to the cheaper 256k wire tier.
if (this.model) {
this.agent.setModel(this._applyContextTier(this.model));
}

// Get the saved compaction entry for the extension event
const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as
Expand Down Expand Up @@ -2742,7 +2844,7 @@ export class AgentSession {
return;
}

this.agent.setModel(refreshedModel);
this.agent.setModel(this._applyContextTier(refreshedModel));
this._refreshThinkingDisplay(refreshedModel);
}

Expand Down Expand Up @@ -3387,7 +3489,7 @@ export class AgentSession {
(m) => m.provider === sessionContext.model!.provider && m.id === sessionContext.model!.modelId,
);
if (match) {
this.agent.setModel(match);
this.agent.setModel(this._applyContextTier(match));
this._refreshThinkingDisplay(match);
await this._emitModelSelect(match, previousModel, "restore");
}
Expand Down
Loading
Loading