From f5036769b090908706993f3acb1795d99d013798 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 22 Sep 2026 14:46:20 -0700 Subject: [PATCH 1/2] feat: [routing] send altimate routing hint and register altimate-auto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 client instrumentation for the gateway's model-routing work (see docs/internal/2026-09-22-gateway-model-routing-research.md, client section). No routing decision changes client-side; this only adds the observability hint and a new selectable model alias. - Verified `@ai-sdk/openai-compatible@2.0.41`'s chat language model spreads unrecognized `providerOptions[]` keys directly into the request body (only `user`/`reasoningEffort`/`textVerbosity`/`strictJsonSchema` are filtered out), so `metadata` rides through the existing providerOptions plumbing with no fetch-wrapper hack needed. - `session/llm.ts`: attach `metadata.altimate` (`task_kind`, `agent`, `tools`, `session_pos`, `message_id`, optional `min_tier`) to the outgoing body for the `altimate-free`/`altimate-backend` providers only. Injected in `stream()` itself (not inside `ProviderTransform.options()`) because small-model calls use `ProviderTransform.smallOptions()` and never reach `options()`. - Stamp `task_kind` explicitly at every call site: main loop / subagent / summary (`session/prompt.ts`, branching on `session.parentID` and agent name), title (`ensureTitle`), compaction (`session/compaction.ts`), skill-selector, enhance-prompt, ai-review, project-copy. Anything else reports `"other"`. - `provider/transform.ts`: new `AltimateTaskKind` enum, `isAltimateManagedModel()` (covers `altimate-base` and `altimate-auto`, replacing three `id.includes("altimate-base")` checks), and `isAltimateManagedProviderID()`. - `provider/provider.ts` + `altimate/free/client.ts`: register a second hand-registered model, `altimate-auto` ("Altimate Auto"), under the `altimate-free` provider with the same shape/limits as Altimate Base. Default-model selection is unchanged — the whole `altimate-free` provider is already excluded from `Provider.defaultModel()`'s ordinary scan. - `skill-selector.ts`/`system.ts`/`prompt.ts`: when the session's model is already Altimate-managed, skill selection reuses it instead of always resolving `Provider.defaultModel()`. Not applied to `enhance-prompt.ts`, `ai-review.ts`, or the `skill` tool's own init-time description builder — none of those call sites currently have a session/model in scope without a larger refactor (documented in code comments). - Tests: `test/session/llm.test.ts` adds an end-to-end request-capture test (real `@ai-sdk/openai-compatible` serialization against a local HTTP server) asserting the outgoing body carries `metadata.altimate` with the expected fields, the `"other"` default, and that non-Altimate providers never get the field. Verified: marker guard clean (no upstream-shared files touched), `tsgo --noEmit` clean, and `bun test` green for every touched file. Co-Authored-By: Claude Fable 5.1 --- .../opencode/src/altimate/enhance-prompt.ts | 2 + packages/opencode/src/altimate/free/client.ts | 5 + .../opencode/src/altimate/review/ai-review.ts | 2 + .../opencode/src/altimate/skill-selector.ts | 26 +- packages/opencode/src/provider/provider.ts | 28 ++ packages/opencode/src/provider/transform.ts | 38 ++- .../instance/httpapi/handlers/project-copy.ts | 2 + packages/opencode/src/session/compaction.ts | 2 + packages/opencode/src/session/llm.ts | 31 +++ packages/opencode/src/session/prompt.ts | 11 +- packages/opencode/src/session/system.ts | 11 +- packages/opencode/test/session/llm.test.ts | 260 ++++++++++++++++++ 12 files changed, 408 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/altimate/enhance-prompt.ts b/packages/opencode/src/altimate/enhance-prompt.ts index 9e1981ab09..0cda3e1813 100644 --- a/packages/opencode/src/altimate/enhance-prompt.ts +++ b/packages/opencode/src/altimate/enhance-prompt.ts @@ -124,6 +124,8 @@ export async function enhancePrompt(text: string): Promise { abort: controller.signal, sessionID: user.sessionID, retries: 2, + // altimate_change — routing hint (Phase 0) + taskKind: "enhance", messages: [ { role: "user", diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index 227464fc48..6bc012d57d 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -12,6 +12,11 @@ const log = Log.create({ service: "altimate-base" }) export const PROVIDER_ID = "altimate-free" export const MODEL_ID = "altimate-base" +// altimate_change start — routing hint (Phase 0): a second, selectable alias under the same +// managed provider. The gateway resolves it server-side; the client only needs to register and +// advertise it. See provider.ts's `baseModels` and transform.ts's `isAltimateManagedModel`. +export const AUTO_MODEL_ID = "altimate-auto" +// altimate_change end // The OpenAI-compatible SDK requires a non-empty key, but the real managed key must never enter // Provider.Info/options because those objects are returned by public provider endpoints. export const MANAGED_API_KEY_PLACEHOLDER = "altimate-base-managed" diff --git a/packages/opencode/src/altimate/review/ai-review.ts b/packages/opencode/src/altimate/review/ai-review.ts index 1bd10a99e8..d4e0e8b737 100644 --- a/packages/opencode/src/altimate/review/ai-review.ts +++ b/packages/opencode/src/altimate/review/ai-review.ts @@ -118,6 +118,8 @@ export async function runAiReview(input: AiReviewInput): Promise { abort: controller.signal, sessionID: user.sessionID, retries: 1, + // altimate_change — routing hint (Phase 0) + taskKind: "review", messages: [{ role: "user", content: buildUserMessage({ ...input, files }) }], }) for await (const _ of stream.fullStream) { diff --git a/packages/opencode/src/altimate/skill-selector.ts b/packages/opencode/src/altimate/skill-selector.ts index bfad73d3c8..47fa0d1ec3 100644 --- a/packages/opencode/src/altimate/skill-selector.ts +++ b/packages/opencode/src/altimate/skill-selector.ts @@ -1,5 +1,7 @@ // altimate_change start - LLM-based dynamic skill selection import { Provider } from "../provider/provider" +import { ProviderTransform } from "../provider/transform" +import { ModelID, ProviderID } from "../provider/schema" import { LLM } from "../session/llm" import { Agent } from "../agent/agent" import { Log } from "@/altimate/util/log" @@ -39,6 +41,9 @@ export async function selectSkillsWithLLM( skills: Skill.Info[], fingerprint: Fingerprint.Result | undefined, deps?: SkillSelectorDeps, + // altimate_change — routing hint (Phase 0): the invoking session's own model, so an Altimate- + // managed session reuses its own model here instead of always resolving Provider.defaultModel(). + sessionModel?: { providerID: string; modelID: string }, ): Promise { const startTime = Date.now() @@ -87,7 +92,7 @@ export async function selectSkillsWithLLM( if (deps) { selected = await deps.run(prompt, skillNames) } else { - selected = await runWithLLM(prompt, skillNames) + selected = await runWithLLM(prompt, skillNames, sessionModel) } selected = selected.slice(0, MAX_SKILLS) @@ -144,9 +149,20 @@ const SYSTEM_PROMPT = [ "Do not include explanations or formatting — just the skill names.", ].join("\n") -async function runWithLLM(prompt: string, validNames: string[]): Promise { - const defaultModel = await Provider.defaultModel() - const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID) +async function runWithLLM( + prompt: string, + validNames: string[], + sessionModel?: { providerID: string; modelID: string }, +): Promise { + // altimate_change start — routing hint (Phase 0): reuse the session's own model when it's + // already an Altimate-managed one, instead of always resolving Provider.defaultModel() (which + // may pick a different, unrelated default and lose the routing hint's session continuity). + const resolved: { providerID: ProviderID; modelID: ModelID } = + sessionModel && ProviderTransform.isAltimateManagedProviderID(sessionModel.providerID) + ? { providerID: ProviderID.make(sessionModel.providerID), modelID: ModelID.make(sessionModel.modelID) } + : await Provider.defaultModel() + const model = await Provider.getModel(resolved.providerID, resolved.modelID) + // altimate_change end const agent: Agent.Info = { name: SELECTOR_NAME, @@ -183,6 +199,8 @@ async function runWithLLM(prompt: string, validNames: string[]): Promise id.includes(managed)) + } + + // Provider-level check (as opposed to the model-id check above): true for both Altimate-managed + // gateway providers, "altimate-free" (the hosted free/auto aliases) and "altimate-backend" + // (tenant/pro). Used by background call sites (enhance-prompt, skill-selector, ai-review) to + // decide whether to prefer the session's own model over Provider.defaultModel(). + export function isAltimateManagedProviderID(providerID: string): boolean { + return providerID === "altimate-free" || providerID === "altimate-backend" + } + // altimate_change end export function sanitizeSurrogates(content: string) { return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? id.includes(s))) { return 0.95 @@ -825,7 +857,7 @@ export namespace ProviderTransform { id.includes("qwen") || id.includes("big-pickle") || // altimate_change — same served-model reasoning as temperature()/topP() above. - id.includes("altimate-base") + isAltimateManagedModel(id) ) return {} // altimate_change end diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts index 84a0a71652..e1ab2df618 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts @@ -48,6 +48,8 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec model, sessionID, retries: 2, + // altimate_change — routing hint (Phase 0) + taskKind: "project_copy", messages: [{ role: "user", content: `Generate a short 2-3 word name that describes this task:\n${text}` }], }) .pipe( diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index bb6a638fef..514ad99f77 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -1545,6 +1545,8 @@ When constructing the summary, try to stick to this template: tools: {}, system: [], toolChoice: "none" as const, + // altimate_change — routing hint (Phase 0) + taskKind: "compaction", messages: [ // altimate_change start — upstream_fix: summarize only the selected head when preserving recent tail; // trim the head from the front when even the summarization request cannot fit the window diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 19c5b7f165..75d7dc879a 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -57,6 +57,13 @@ export namespace LLM { tools: Record retries?: number toolChoice?: "auto" | "required" | "none" + // altimate_change start — routing hint (Phase 0): what kind of call this is, for the + // Altimate-managed providers only. Optional so every existing call site (and every fixture + // in the test suite) keeps compiling unchanged; call sites that care stamp an explicit value, + // everything else reports "other" — see the `altimateHint` block in `stream()` below. + taskKind?: ProviderTransform.AltimateTaskKind + minTier?: "utility" | "standard" | "strong" + // altimate_change end } export type StreamOutput = StreamTextResult @@ -271,6 +278,30 @@ export namespace LLM { const declaresNoTools = Object.keys(tools).filter((x) => x !== "invalid").length === 0 // altimate_change end + // altimate_change start — routing hint (Phase 0): attach `metadata.altimate` to the outgoing + // body for the Altimate-managed providers only. Phase 0 is observational only on the gateway + // side (no routing decision reads it yet) — see + // docs/internal/2026-09-22-gateway-model-routing-research.md. Injected here, after tool + // resolution/historical-stub injection and before providerOptions is built, rather than inside + // ProviderTransform.options(): small-model calls (title, enhance-prompt, project-copy) use + // ProviderTransform.smallOptions() instead and never reach options(), so a branch inside + // options() would silently miss them. + if (ProviderTransform.isAltimateManagedProviderID(input.model.providerID)) { + const altimateHint: Record = { + task_kind: input.taskKind ?? "other", + agent: input.agent.name, + tools: Object.keys(tools).filter((x) => x !== "invalid").length, + session_pos: Math.min(input.messages.length, 100_000), + message_id: input.user.id, + } + if (input.minTier) altimateHint.min_tier = input.minTier + requestOptions["metadata"] = { + ...(requestOptions["metadata"] as Record | undefined), + altimate: altimateHint, + } + } + // altimate_change end + return streamText({ onError(error) { l.error("stream error", { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b2ebabeefe..f2d01e045c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1442,7 +1442,8 @@ export namespace SessionPrompt { // made models echo the date back on every turn. // Build system prompt, adding structured output instruction if needed - const skills = await SystemPrompt.skills(agent) + // altimate_change — routing hint (Phase 0): pass the session's model through + const skills = await SystemPrompt.skills(agent, model) // altimate_change start - unified context-aware injection for memory + training const knowledgeInjection = Flag.ALTIMATE_DISABLE_MEMORY ? "" @@ -1544,6 +1545,12 @@ export namespace SessionPrompt { abort, sessionID, system, + // altimate_change start — routing hint (Phase 0): a child session (task tool) is a + // subagent turn; the "summary" agent has no tools/reasoning of its own; everything else + // sharing this loop() call site is the primary/main turn. Title and compaction build their + // own LLM.StreamInput objects directly and stamp their own task_kind there. + taskKind: session.parentID ? "subagent" : agent.name === "summary" ? "summary" : "main", + // altimate_change end messages: [ ...(await MessageV2.toModelMessages(msgs, model)), ...(isLastStep @@ -4079,6 +4086,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the system: [], small: true, tools: {}, + // altimate_change — routing hint (Phase 0) + taskKind: "title", // altimate_change start — title generation is toolless, but without an explicit "none" the // historical-tool-stub injection in LLM.stream repopulates `tools` from any tool parts in // the context, which both re-declares tools this request cannot use and suppresses the diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 00b9ca62a8..d89b8ae6a2 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -107,7 +107,9 @@ export namespace SystemPrompt { } // altimate_change end - export async function skills(agent: Agent.Info) { + // altimate_change — routing hint (Phase 0): optional session model, forwarded to + // selectSkillsWithLLM so an Altimate-managed session reuses its own model there. + export async function skills(agent: Agent.Info, model?: Provider.Model) { if (PermissionNext.disabled(["skill"], agent.permission).has("skill")) return const list = await Skill.available(agent) @@ -116,7 +118,12 @@ export namespace SystemPrompt { const cfg = await Config.get() let filtered: Skill.Info[] if (cfg.experimental?.env_fingerprint_skill_selection === true) { - filtered = await selectSkillsWithLLM(list, Fingerprint.get()) + filtered = await selectSkillsWithLLM( + list, + Fingerprint.get(), + undefined, + model ? { providerID: model.providerID, modelID: model.id } : undefined, + ) } else { filtered = list } diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 3742092b03..332b287562 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -926,3 +926,263 @@ describe("session.llm.stream", () => { }) }, 30_000) }) + +// altimate_change start — routing hint (Phase 0): verifies the outgoing chat.completions body +// carries `metadata.altimate` for the Altimate-managed providers, end-to-end through +// ProviderTransform.options()/providerOptions() and the real @ai-sdk/openai-compatible request +// serialization (see docs/internal/2026-09-22-gateway-model-routing-research.md, client section). +// Uses "altimate-backend" (not "altimate-free") because the free-tier provider is deliberately +// excluded from config-based registration (`Provider.ts`'s `configProviders` filter) — the +// managed-consent gate that "altimate-backend" doesn't have — so it's the one Altimate-managed +// provider a test can point at a local server via plain `opencode.json` config, exactly like the +// "sends responses API payload for OpenAI models" test above does for "openai". The metadata +// injection itself is provider-ID gated (`ProviderTransform.isAltimateManagedProviderID`), so +// what's exercised here — the body actually carrying `metadata.altimate` — is identical for +// "altimate-free". +describe("session.llm.stream - altimate routing hint (Phase 0)", () => { + test("sends metadata.altimate with task_kind/agent/tools/session_pos/message_id", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const request = waitRequest( + "/chat/completions", + new Response(createChatStream("Hello"), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://altimate.ai/config.json", + provider: { + "altimate-backend": { + options: { + baseURL: `${server.url.origin}/agents/v1`, + apiKey: "test-altimate-backend-key", + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await Provider.getModel(ProviderID.make("altimate-backend"), ModelID.make("altimate-default")) + const sessionID = SessionID.make("session-test-altimate-hint") + const agent = { + name: "build", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + + const user = { + id: MessageID.make("msg_user_hint_1"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make("altimate-backend"), modelID: resolved.id }, + } satisfies MessageV2.User + + const oneTool: Record = { + bash: tool({ + description: "run a shell command", + inputSchema: jsonSchema({ type: "object", properties: {} }), + }), + } + + const stream = await LLM.stream({ + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + abort: new AbortController().signal, + messages: [{ role: "user", content: "Hello" }], + tools: oneTool, + taskKind: "review", + }) + + for await (const _ of stream.fullStream) { + } + + const capture = await request + const metadata = capture.body.metadata as Record | undefined + const altimate = metadata?.altimate as Record | undefined + expect(altimate).toBeDefined() + expect(altimate?.task_kind).toBe("review") + expect(altimate?.agent).toBe("build") + expect(altimate?.tools).toBe(1) + expect(altimate?.session_pos).toBe(1) + expect(altimate?.message_id).toBe("msg_user_hint_1") + }, + }) + }, 30_000) + + test("defaults task_kind to 'other' when the call site doesn't stamp one", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const request = waitRequest( + "/chat/completions", + new Response(createChatStream("Hello"), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://altimate.ai/config.json", + provider: { + "altimate-backend": { + options: { + baseURL: `${server.url.origin}/agents/v1`, + apiKey: "test-altimate-backend-key", + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await Provider.getModel(ProviderID.make("altimate-backend"), ModelID.make("altimate-default")) + const sessionID = SessionID.make("session-test-altimate-hint-default") + const agent = { + name: "build", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + + const user = { + id: MessageID.make("msg_user_hint_2"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make("altimate-backend"), modelID: resolved.id }, + } satisfies MessageV2.User + + const stream = await LLM.stream({ + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + abort: new AbortController().signal, + messages: [{ role: "user", content: "Hello" }], + tools: {}, + // no taskKind — must default to "other", never crash or omit metadata + }) + + for await (const _ of stream.fullStream) { + } + + const capture = await request + const metadata = capture.body.metadata as Record | undefined + const altimate = metadata?.altimate as Record | undefined + expect(altimate?.task_kind).toBe("other") + }, + }) + }, 30_000) + + test("does not attach metadata.altimate for non-Altimate providers", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const source = await loadFixture("alibaba", "qwen-plus") + const model = source.model + + const request = waitRequest( + "/chat/completions", + new Response(createChatStream("Hello"), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://altimate.ai/config.json", + enabled_providers: ["alibaba"], + provider: { + alibaba: { + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await Provider.getModel(ProviderID.make("alibaba"), ModelID.make(model.id)) + const sessionID = SessionID.make("session-test-non-altimate-hint") + const agent = { + name: "build", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + + const user = { + id: MessageID.make("msg_user_hint_3"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make("alibaba"), modelID: resolved.id }, + } satisfies MessageV2.User + + const stream = await LLM.stream({ + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + abort: new AbortController().signal, + messages: [{ role: "user", content: "Hello" }], + tools: {}, + taskKind: "review", + }) + + for await (const _ of stream.fullStream) { + } + + const capture = await request + expect(capture.body.metadata).toBeUndefined() + }, + }) + }, 30_000) +}) +// altimate_change end From b041cd6a4de0f1ac901bb16d1a63dadd748faec0 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 22 Sep 2026 15:21:25 -0700 Subject: [PATCH 2/2] fix: [routing] address Codex review of the altimate-auto hint (rollout gate, id join, markers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes for f5036769b0's Codex review ("needs changes"): 1. BLOCKER — rollout gate. `altimate-auto` was registered as an active, pickable model unconditionally, but the gateway 403s the alias until its own Phase 0a rollout ships. Gated registration behind a new `Flag.ALTIMATE_AUTO_MODEL` (env `ALTIMATE_AUTO_MODEL`, default off, read once at `flag.ts` import time like every other Flag.* gate). When off, `provider.ts`'s `baseModels` does not include the `altimate-auto` entry at all — the routing-hint metadata plumbing in session/llm.ts is unaffected either way. Flip the default only after the gateway's Phase 0a allowlist/rewrite deploys. Tests: `provider.test.ts` covers both states — the flag itself (subprocess, matching `external-skills-flag.test.ts`'s established pattern for import-time env flags) and the actual registration behavior in-process (off by default; on via a direct Flag-object mutation, restored after). 2. MAJOR — message_id join with the generation telemetry event. session/llm.ts previously always used `input.user.id` for the hint's `message_id`, but processor.ts's `generation` telemetry event (Telemetry.track({ type: "generation", message_id: input.assistantMessage.id })) uses the assistant message's id — a different id for every call that goes through a processor turn. Added `StreamInput.messageId`, set by processor.ts to `assistantMessage.id` right before calling LLM.stream (covers main, subagent, summary, AND compaction uniformly, since compaction also goes through SessionProcessor.create()/process() and creates its own summary assistant message — note this differs slightly from the review's parenthetical list, which named compaction among "calls with no assistant message"; compaction does have one, and joining it to the same id as its own generation event is the technically correct behavior). Call sites with no processor turn at all set `messageId` explicitly to the message they're about when one exists (title: the first real user message) or leave it unset — `message_id` is then omitted from the hint entirely (skill-selector, enhance-prompt, ai-review, project-copy all construct synthetic per-call messages with no real "message this is about"). Tests: llm.test.ts's omission/explicit-id cases, plus a new processor-effect.test.ts end-to-end test that drives a real main-turn against the real "altimate-backend" provider and asserts the captured request's `metadata.altimate.message_id` equals the assistant message's own id (the same id `generation` telemetry reads). 3. MAJOR — marker gate. Two real gaps: server/routes/.../project-copy.ts:52 and session/prompt.ts:1446 had single-line `// altimate_change — ...` comments instead of a `start`/`end` wrap. Fixed both. Also fixed a pre-existing imbalance in provider/transform.ts from f5036769b0 itself: an Edit there had unintentionally split one existing marked block into two, leaving a duplicate `// altimate_change end` orphaned after `sanitizeSurrogates()` — restructured so the original block (const + sanitizeSurrogates) closes exactly where it did before, and the new routing-hint block (AltimateTaskKind, isAltimateManagedModel, isAltimateManagedProviderID) is its own separate, correctly closed block after it. `bun run script/upstream/analyze.ts --markers --base origin/main --strict` now exits 0. 4. MINOR — hardening + tests. - `isAltimateManagedModel` is now an exact Set membership check ("altimate-base" / "altimate-auto") instead of substring `.includes()`. - The hint's `tools` count is clamped to 512 (the gateway's cap) instead of sent uncapped. - The hint's `agent` field is validated against the gateway's own ^[a-z][a-z0-9_-]{0,31}$ allowlist and dropped (not sent) on a mismatch, rather than relying on the gateway to silently drop an invalid key. - New tests in llm.test.ts: a `small: true` + real "altimate-free" provider path (mirrors the actual title/enhance-prompt/project-copy shape — small-model calls use ProviderTransform.smallOptions(), not .options(), which is exactly why the hint is injected in stream() itself rather than inside options()), and a tools-clamp + invalid-agent-name-dropped case. - New tests in skill-filtering.test.ts covering skill-selector's session-model reuse: an Altimate-managed session model skips Provider.defaultModel() entirely; a non-Altimate session model (or no session model at all) still falls back to it. Verified: marker guard clean, `tsgo --noEmit` clean, and `bun test` green for every touched file. One pre-existing, order-dependent flake was observed once in processor-effect.test.ts (`it.live`-based Effect fiber cleanup racing across combined test files — the same "Unhandled error between tests: All fibers interrupted without error" pattern reproduces on the unmodified "capture llm input cleanly" test when run filtered/isolated) and did not reproduce on three immediate retries of the identical combined file set; not caused by this change. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/flag/flag.ts | 6 + packages/opencode/src/provider/provider.ts | 56 +++--- packages/opencode/src/provider/transform.ts | 18 +- .../instance/httpapi/handlers/project-copy.ts | 3 +- packages/opencode/src/session/llm.ts | 27 ++- packages/opencode/src/session/processor.ts | 8 + packages/opencode/src/session/prompt.ts | 7 +- .../test/altimate/skill-filtering.test.ts | 117 ++++++++++- .../opencode/test/provider/provider.test.ts | 96 +++++++++ packages/opencode/test/session/llm.test.ts | 184 +++++++++++++++++- .../test/session/processor-effect.test.ts | 74 +++++++ 11 files changed, 557 insertions(+), 39 deletions(-) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index a833dedff5..49c57e6db5 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -73,6 +73,12 @@ export namespace Flag { // altimate_change start - opt-in for session-end auto-extraction export const ALTIMATE_MEMORY_AUTO_EXTRACT = altTruthy("ALTIMATE_MEMORY_AUTO_EXTRACT", "OPENCODE_MEMORY_AUTO_EXTRACT") // altimate_change end + // altimate_change start — rollout gate (Phase 0): `altimate-auto` is registered as a pickable + // model only when this is set. Default OFF because the gateway rejects the alias with a 403 + // until its Phase 0a rollout deploys — flip the default to on only after that ships. See + // provider.ts's `baseModels` and docs/internal/2026-09-22-gateway-model-routing-research.md. + export const ALTIMATE_AUTO_MODEL = truthy("ALTIMATE_AUTO_MODEL") + // altimate_change end // altimate_change start - yolo mode: auto-approve all permission prompts // Declared here, defined via dynamic getter below (must evaluate at access time // because --yolo CLI flag sets the env var in middleware after module load) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 59a9650f3c..a7aaf28637 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1599,29 +1599,39 @@ export namespace Provider { // as Altimate Base. The gateway resolves it to a concrete served model server-side (Phase 0: // a plain rewrite to Altimate Base); the client only registers it as a pickable model. Not // added to the default-model priority list or scan — see Provider.defaultModel(). - [FreeTier.AUTO_MODEL_ID]: { - id: ModelID.make(FreeTier.AUTO_MODEL_ID), - providerID: ProviderID.make(FreeTier.PROVIDER_ID), - name: "Altimate Auto", - family: "altimate", - api: { id: FreeTier.AUTO_MODEL_ID, url: "", npm: "@ai-sdk/openai-compatible" }, - status: "active", - headers: {}, - options: {}, - cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - limit: { context: 131_072, output: 65_536 }, - capabilities: { - temperature: true, - reasoning: true, - attachment: false, - toolcall: true, - input: { text: true, audio: false, image: false, video: false, pdf: false }, - output: { text: true, audio: false, image: false, video: false, pdf: false }, - interleaved: false, - }, - release_date: "2026-08-29", - variants: {}, - }, + // + // Rollout gate: OFF by default. Until the gateway's Phase 0a allowlist/rewrite deploys, the + // gateway 403s every request that names this alias — registering it unconditionally would put + // a broken, always-failing model in every model picker. Flip Flag.ALTIMATE_AUTO_MODEL's + // default only after that gateway change ships. The routing-hint metadata plumbing in + // session/llm.ts is NOT gated by this flag and stays on regardless. + ...(Flag.ALTIMATE_AUTO_MODEL + ? { + [FreeTier.AUTO_MODEL_ID]: { + id: ModelID.make(FreeTier.AUTO_MODEL_ID), + providerID: ProviderID.make(FreeTier.PROVIDER_ID), + name: "Altimate Auto", + family: "altimate", + api: { id: FreeTier.AUTO_MODEL_ID, url: "", npm: "@ai-sdk/openai-compatible" }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 131_072, output: 65_536 }, + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "2026-08-29", + variants: {}, + }, + } + : {}), // altimate_change end } database[FreeTier.PROVIDER_ID] = { diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 50a805595e..377872b450 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -21,9 +21,13 @@ export namespace ProviderTransform { export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000 // altimate_change start — keep OpenAI encrypted reasoning include values consistent across transforms const INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] as const + + export function sanitizeSurrogates(content: string) { + return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? = new Set(["altimate-base", "altimate-auto"]) export function isAltimateManagedModel(id: string): boolean { - return ALTIMATE_MANAGED_MODEL_IDS.some((managed) => id.includes(managed)) + return ALTIMATE_MANAGED_MODEL_IDS.has(id) } // Provider-level check (as opposed to the model-id check above): true for both Altimate-managed @@ -54,11 +61,6 @@ export namespace ProviderTransform { } // altimate_change end - export function sanitizeSurrogates(content: string) { - return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? = { task_kind: input.taskKind ?? "other", - agent: input.agent.name, - tools: Object.keys(tools).filter((x) => x !== "invalid").length, + // The gateway allowlists `agent` against ^[a-z][a-z0-9_-]{0,31}$ and silently drops the + // whole key on a mismatch rather than rejecting the request — validate client-side too so + // an unexpected agent name (a mode alias, a plugin-provided name, anything with a capital + // or a character outside the allowed set) doesn't send a key the gateway would ignore + // anyway, and doesn't leak an arbitrary string unchecked. + ...(ALTIMATE_HINT_AGENT_RE.test(input.agent.name) ? { agent: input.agent.name } : {}), + // Same allowlist cap as the gateway (0..512) — clamp rather than send a value it would + // reject outright. + tools: Math.min(Object.keys(tools).filter((x) => x !== "invalid").length, 512), session_pos: Math.min(input.messages.length, 100_000), - message_id: input.user.id, + ...(input.messageId ? { message_id: input.messageId } : {}), } if (input.minTier) altimateHint.min_tier = input.minTier requestOptions["metadata"] = { diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 938489152d..2f34d538ef 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -404,6 +404,14 @@ export namespace SessionProcessor { } } // altimate_change end + // altimate_change start — routing hint (Phase 0): join the outgoing hint's message_id to + // the same id the `generation` telemetry event below already uses + // (Telemetry.track({ type: "generation", message_id: input.assistantMessage.id, ... })), + // so client-side routing observability and telemetry can be correlated on one id. Applied + // last (not folded into the assignments above) so it survives regardless of whether the + // nudge-directive branch reassigned `effectiveStreamInput`. + effectiveStreamInput = { ...effectiveStreamInput, messageId: input.assistantMessage.id } + // altimate_change end while (true) { try { let currentText: MessageV2.TextPart | undefined diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f2d01e045c..ef3152e541 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1442,8 +1442,9 @@ export namespace SessionPrompt { // made models echo the date back on every turn. // Build system prompt, adding structured output instruction if needed - // altimate_change — routing hint (Phase 0): pass the session's model through + // altimate_change start — routing hint (Phase 0): pass the session's model through const skills = await SystemPrompt.skills(agent, model) + // altimate_change end // altimate_change start - unified context-aware injection for memory + training const knowledgeInjection = Flag.ALTIMATE_DISABLE_MEMORY ? "" @@ -4086,8 +4087,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the system: [], small: true, tools: {}, - // altimate_change — routing hint (Phase 0) + // altimate_change start — routing hint (Phase 0): the message this call is about taskKind: "title", + messageId: firstRealUser.info.id, + // altimate_change end // altimate_change start — title generation is toolless, but without an explicit "none" the // historical-tool-stub injection in LLM.stream repopulates `tools` from any tool parts in // the context, which both re-declares tools this request cannot use and suppresses the diff --git a/packages/opencode/test/altimate/skill-filtering.test.ts b/packages/opencode/test/altimate/skill-filtering.test.ts index 17f6d5f469..bda986a307 100644 --- a/packages/opencode/test/altimate/skill-filtering.test.ts +++ b/packages/opencode/test/altimate/skill-filtering.test.ts @@ -1,5 +1,7 @@ -import { beforeEach, describe, expect, test } from "bun:test" +import { beforeEach, describe, expect, spyOn, test } from "bun:test" import { selectSkillsWithLLM, resetSkillSelectorCache, type SkillSelectorDeps } from "../../src/altimate/skill-selector" +import { Provider } from "../../src/provider/provider" +import { LLM } from "../../src/session/llm" import type { Skill } from "../../src/skill" import type { Fingerprint } from "../../src/altimate/fingerprint" @@ -175,3 +177,116 @@ describe("selectSkillsWithLLM", () => { }) }) + +// altimate_change start — routing hint (Phase 0): skill-selector.ts's `runWithLLM` reuses the +// invoking session's own model instead of always resolving Provider.defaultModel() when that +// session model is Altimate-managed (altimate-free / altimate-backend). No `deps` here — that +// bypasses `runWithLLM` (and its model-resolution logic) entirely, which is what every other test +// in this file uses deliberately. +function fakeModel(providerID: string, modelID: string): any { + return { + id: modelID, + providerID, + name: modelID, + api: { id: modelID, url: "", npm: "@ai-sdk/openai-compatible" }, + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: false, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 1000, output: 1000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2025-01-01", + } +} + +function stubLLMStream(text: string) { + return spyOn(LLM, "stream").mockResolvedValue({ + // eslint-disable-next-line @typescript-eslint/require-yield + fullStream: (async function* () {})(), + text: Promise.resolve(text), + } as any) +} + +describe("skill-selector session-model reuse (routing hint Phase 0)", () => { + beforeEach(() => { + resetSkillSelectorCache() + }) + + test("reuses the session's model when it is Altimate-managed, skipping Provider.defaultModel()", async () => { + const defaultModelSpy = spyOn(Provider, "defaultModel").mockResolvedValue({ + providerID: "opencode", + modelID: "big-pickle", + } as any) + const getModelSpy = spyOn(Provider, "getModel").mockImplementation( + async (providerID: any, modelID: any) => fakeModel(providerID, modelID) as any, + ) + const streamSpy = stubLLMStream("dbt-modeling") + + try { + await selectSkillsWithLLM(ALL_SKILLS, mockFingerprint(["dbt"]), undefined, { + providerID: "altimate-backend", + modelID: "altimate-default", + }) + expect(getModelSpy).toHaveBeenCalledWith("altimate-backend", "altimate-default") + expect(defaultModelSpy).not.toHaveBeenCalled() + } finally { + defaultModelSpy.mockRestore() + getModelSpy.mockRestore() + streamSpy.mockRestore() + } + }) + + test("falls back to Provider.defaultModel() when the session model is not Altimate-managed", async () => { + const defaultModelSpy = spyOn(Provider, "defaultModel").mockResolvedValue({ + providerID: "opencode", + modelID: "big-pickle", + } as any) + const getModelSpy = spyOn(Provider, "getModel").mockImplementation( + async (providerID: any, modelID: any) => fakeModel(providerID, modelID) as any, + ) + const streamSpy = stubLLMStream("dbt-modeling") + + try { + await selectSkillsWithLLM(ALL_SKILLS, mockFingerprint(["dbt"]), undefined, { + providerID: "anthropic", + modelID: "claude-x", + }) + expect(defaultModelSpy).toHaveBeenCalled() + expect(getModelSpy).toHaveBeenCalledWith("opencode", "big-pickle") + } finally { + defaultModelSpy.mockRestore() + getModelSpy.mockRestore() + streamSpy.mockRestore() + } + }) + + test("falls back to Provider.defaultModel() when no session model is given", async () => { + const defaultModelSpy = spyOn(Provider, "defaultModel").mockResolvedValue({ + providerID: "opencode", + modelID: "big-pickle", + } as any) + const getModelSpy = spyOn(Provider, "getModel").mockImplementation( + async (providerID: any, modelID: any) => fakeModel(providerID, modelID) as any, + ) + const streamSpy = stubLLMStream("dbt-modeling") + + try { + await selectSkillsWithLLM(ALL_SKILLS, mockFingerprint(["dbt"])) + expect(defaultModelSpy).toHaveBeenCalled() + expect(getModelSpy).toHaveBeenCalledWith("opencode", "big-pickle") + } finally { + defaultModelSpy.mockRestore() + getModelSpy.mockRestore() + streamSpy.mockRestore() + } + }) +}) +// altimate_change end diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index be1c93a731..654fbe4dde 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -3058,4 +3058,100 @@ test("fromModelsDevProvider survives a catalog with a malformed entry mixed in", expect(database["no-models"]).toBeUndefined() expect(database["bad-id"]).toBeUndefined() }) + +// altimate_change start — routing hint (Phase 0): "altimate-auto" rollout gate +// (Flag.ALTIMATE_AUTO_MODEL, default off — the gateway 403s the alias until its own +// Phase 0a deploys). Two things need covering: (1) the env var itself is read once, at +// `flag.ts` import time — verified the same way every other import-time flag in this +// codebase is (`test/flag/external-skills-flag.test.ts`'s `flag()` helper), via a fresh +// `bun -e` subprocess so the env is actually re-read; and (2) `provider.ts`'s +// registration honors that flag, verified in-process against a real `Provider.list()`. +// For (2), `Flag.ALTIMATE_AUTO_MODEL` is flipped directly on the (mutable at runtime) +// `Flag` namespace object rather than via env + a fresh subprocess: `Provider.list()` +// pulls in Config/Auth/Env and a dozen other modules that `test/preload.ts` isolates +// from the developer's real home/XDG dirs for the whole `bun test` run (see its +// `OPENCODE_TEST_HOME`/`XDG_*` setup) — a raw subprocess doesn't get that isolation +// for free, and re-deriving it here would risk exactly the non-hermetic, real-home- +// touching test this suite's own preload was written to prevent. Reading +// `Flag.ALTIMATE_AUTO_MODEL` itself is already covered by the subprocess test below. +async function altimateAutoModelFlag(env: Record): Promise { + const script = `import { Flag } from "./src/flag/flag"; console.log(JSON.stringify(Flag.ALTIMATE_AUTO_MODEL ?? null))` + const proc = Bun.spawn(["bun", "-e", script], { + cwd: path.resolve(import.meta.dir, "../.."), + env: { PATH: process.env.PATH!, HOME: process.env.HOME!, NODE_OPTIONS: "", ...env }, + stdout: "pipe", + stderr: "pipe", + }) + const out = await new Response(proc.stdout).text() + const code = await proc.exited + if (code !== 0) throw new Error(await new Response(proc.stderr).text()) + return JSON.parse(out.trim()) +} + +test("ALTIMATE_AUTO_MODEL is read once from the environment, off by default", async () => { + expect(await altimateAutoModelFlag({})).toBe(false) + expect(await altimateAutoModelFlag({ ALTIMATE_AUTO_MODEL: "0" })).toBe(false) + expect(await altimateAutoModelFlag({ ALTIMATE_AUTO_MODEL: "1" })).toBe(true) + expect(await altimateAutoModelFlag({ ALTIMATE_AUTO_MODEL: "true" })).toBe(true) +}) + +test("altimate-auto is not registered while the rollout flag is off (today's default)", async () => { + const { Flag } = await import("../../src/flag/flag") + expect((Flag as unknown as Record).ALTIMATE_AUTO_MODEL).toBe(false) + + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ + apiKey: "sk-altimate-base", + baseURL: ALTIMATE_BASE_GATEWAY_URL, + installSecret: "install-secret", + }) + try { + await using tmp = await tmpdir() + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const base = providers[FreeTier.PROVIDER_ID] + expect(base).toBeDefined() + expect(base.models[FreeTier.MODEL_ID]).toBeDefined() + expect(base.models[FreeTier.AUTO_MODEL_ID]).toBeUndefined() + }, + }) + } finally { + credentials.mockRestore() + } +}) + +test("altimate-auto is registered, same shape as altimate-base, once the rollout flag is on", async () => { + const { Flag } = await import("../../src/flag/flag") + const mutableFlag = Flag as unknown as Record + const original = mutableFlag.ALTIMATE_AUTO_MODEL + mutableFlag.ALTIMATE_AUTO_MODEL = true + + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ + apiKey: "sk-altimate-base", + baseURL: ALTIMATE_BASE_GATEWAY_URL, + installSecret: "install-secret", + }) + try { + await using tmp = await tmpdir() + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const base = providers[FreeTier.PROVIDER_ID] + expect(base).toBeDefined() + const auto = base.models[FreeTier.AUTO_MODEL_ID] + expect(auto).toBeDefined() + expect(auto.name).toBe("Altimate Auto") + expect(auto.api).toEqual({ id: FreeTier.AUTO_MODEL_ID, url: "", npm: "@ai-sdk/openai-compatible" }) + expect(auto.limit).toEqual(base.models[FreeTier.MODEL_ID].limit) + expect(auto.capabilities).toEqual(base.models[FreeTier.MODEL_ID].capabilities) + }, + }) + } finally { + mutableFlag.ALTIMATE_AUTO_MODEL = original + credentials.mockRestore() + } +}) +// altimate_change end // altimate_change end diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 332b287562..4bffc0c733 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" +import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test" import path from "path" import { jsonSchema, tool, type ModelMessage, type Tool } from "ai" import { LLM } from "../../src/session/llm" @@ -12,6 +12,7 @@ import { tmpdir } from "../fixture/fixture" import type { Agent } from "../../src/agent/agent" import type { MessageV2 } from "../../src/session/message-v2" import { SessionID, MessageID } from "../../src/session/schema" +import { FreeTier } from "../../src/altimate/free/client" describe("session.llm.toolNamesFromMessages", () => { test("returns empty set for empty messages", () => { @@ -1011,6 +1012,10 @@ describe("session.llm.stream - altimate routing hint (Phase 0)", () => { messages: [{ role: "user", content: "Hello" }], tools: oneTool, taskKind: "review", + // altimate_change — routing hint (Phase 0): callers with no processor turn (this test + // stands in for one) set this explicitly; processor.ts sets it automatically for + // main/subagent/summary/compaction turns (see processor-effect.test.ts). + messageId: "msg_user_hint_1", }) for await (const _ of stream.fullStream) { @@ -1102,10 +1107,187 @@ describe("session.llm.stream - altimate routing hint (Phase 0)", () => { const metadata = capture.body.metadata as Record | undefined const altimate = metadata?.altimate as Record | undefined expect(altimate?.task_kind).toBe("other") + // altimate_change — routing hint (Phase 0): no `messageId` set on this call (mirrors + // skill-selector/enhance-prompt/ai-review/project-copy, which have no message to be + // "about") — `message_id` must be omitted entirely, never a throwaway synthetic id. + expect(altimate).not.toHaveProperty("message_id") }, }) }, 30_000) + test("clamps tools to 512 and drops an invalid agent name", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const request = waitRequest( + "/chat/completions", + new Response(createChatStream("Hello"), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://altimate.ai/config.json", + provider: { + "altimate-backend": { + options: { + baseURL: `${server.url.origin}/agents/v1`, + apiKey: "test-altimate-backend-key", + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await Provider.getModel(ProviderID.make("altimate-backend"), ModelID.make("altimate-default")) + const sessionID = SessionID.make("session-test-altimate-hint-clamp") + // "Custom Agent!" fails the gateway's ^[a-z][a-z0-9_-]{0,31}$ allowlist (uppercase, a + // space, punctuation) — the client must drop the key, not send it and let the gateway + // silently drop it server-side. + const agent = { + name: "Custom Agent!", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + + const user = { + id: MessageID.make("msg_user_hint_clamp"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make("altimate-backend"), modelID: resolved.id }, + } satisfies MessageV2.User + + // 600 tools declared — over the gateway's 512 cap. + const manyTools: Record = Object.fromEntries( + Array.from({ length: 600 }, (_, i) => [ + `tool_${i}`, + tool({ description: "test", inputSchema: jsonSchema({ type: "object", properties: {} }) }), + ]), + ) + + const stream = await LLM.stream({ + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + abort: new AbortController().signal, + messages: [{ role: "user", content: "Hello" }], + tools: manyTools, + taskKind: "main", + }) + + for await (const _ of stream.fullStream) { + } + + const capture = await request + const metadata = capture.body.metadata as Record | undefined + const altimate = metadata?.altimate as Record | undefined + expect(altimate?.tools).toBe(512) + expect(altimate).not.toHaveProperty("agent") + }, + }) + }, 30_000) + + // altimate_change start — routing hint (Phase 0): title/enhance-prompt/project-copy all call + // LLM.stream with `small: true` and no tools, and (unlike the tests above, which use + // "altimate-backend" for config-registration convenience) they run against the real + // "altimate-free" provider in production. Covers both gaps at once: the `small: true` path + // (ProviderTransform.smallOptions(), not .options() — the reason the hint is injected in + // stream() rather than inside options(), see the comment there) and the free-tier provider. + test("attaches metadata.altimate on the small:true altimate-free path (title/enhance/project-copy shape)", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ + apiKey: "sk-altimate-base-fake", + baseURL: server.url.origin, + installSecret: "install-secret", + }) + + const request = waitRequest( + "/chat/completions", + new Response(createChatStream("Untitled Session"), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ) + + try { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await Provider.getModel(ProviderID.make(FreeTier.PROVIDER_ID), ModelID.make(FreeTier.MODEL_ID)) + const sessionID = SessionID.make("session-test-altimate-free-small") + const agent = { + name: "title", + mode: "primary", + hidden: true, + options: {}, + permission: [], + } satisfies Agent.Info + + const user = { + id: MessageID.make("msg_user_free_small"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make(FreeTier.PROVIDER_ID), modelID: resolved.id }, + } satisfies MessageV2.User + + const stream = await LLM.stream({ + user, + sessionID, + model: resolved, + agent, + system: [], + small: true, + tools: {}, + toolChoice: "none", + abort: new AbortController().signal, + messages: [{ role: "user", content: "Generate a title for this conversation:\n" }], + taskKind: "title", + messageId: "msg_user_free_small", + }) + + for await (const _ of stream.fullStream) { + } + + const capture = await request + const metadata = capture.body.metadata as Record | undefined + const altimate = metadata?.altimate as Record | undefined + expect(altimate).toBeDefined() + expect(altimate?.task_kind).toBe("title") + expect(altimate?.agent).toBe("title") + expect(altimate?.tools).toBe(0) + expect(altimate?.message_id).toBe("msg_user_free_small") + }, + }) + } finally { + credentials.mockRestore() + } + }, 30_000) + // altimate_change end + test("does not attach metadata.altimate for non-Altimate providers", async () => { const server = state.server if (!server) { diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index bb68e31364..1c36175d15 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -88,6 +88,24 @@ function providerCfg(url: string) { } } +// altimate_change start — routing hint (Phase 0): config-registers the real "altimate-backend" +// provider (its one model, "altimate-default", is hand-registered in provider.ts and needs no +// `models` block here) against the test LLM server, so a real main-turn drives the real +// session/llm.ts hint injection end to end, not a generic test provider it's not gated on. +function altimateProviderCfg(url: string) { + return { + provider: { + "altimate-backend": { + options: { + baseURL: url, + apiKey: "test-altimate-backend-key", + }, + }, + }, + } +} +// altimate_change end + function agent(): Agent.Info { return { name: "build", @@ -314,6 +332,62 @@ it.live("session.processor effect tests capture llm input cleanly", () => ), ) +// altimate_change start — routing hint (Phase 0): the outgoing metadata.altimate.message_id +// must match the id the `generation` telemetry event records for the same turn +// (processor.ts's Telemetry.track({ type: "generation", message_id: input.assistantMessage.id, +// ... })), or the client-side hint and the gateway/Langfuse-side telemetry can't be joined on +// the same request. Both read `input.assistantMessage.id` — telemetry directly (unchanged, +// pre-existing code), the hint via the new `messageId` field processor.ts threads into the +// LLM.StreamInput it hands to LLM.stream() right before calling it. This test deliberately does +// NOT set `messageId` on the StreamInput it constructs, so a regression that removes processor.ts's +// injection (rather than the test merely echoing it back) makes this fail. Uses the real +// "altimate-backend" provider (not the generic "test" one above) because the hint is gated on +// `ProviderTransform.isAltimateManagedProviderID`. +it.live("session.processor routing hint message_id matches the generation telemetry message id", () => + provideTmpdirServerLegacy( + ({ dir, llm }) => + Effect.gen(function* () { + const database = yield* Database.Service + const { processors, session, provider } = yield* boot() + + yield* llm.text("hello") + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "hi") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ProviderID.make("altimate-backend"), ModelID.make("altimate-default")) + const controller = new AbortController() + const handle = yield* processors.create({ + assistantMessage: msg as unknown as MessageV2.Assistant, + sessionID: chat.id, + model: mdl, + abort: controller.signal, + }) + + const input = { + user: userInput(parent, chat.id), + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "hi" }], + tools: {}, + taskKind: "main", + } satisfies Omit + + const value = yield* runProcess(handle, controller, input) + const inputs = yield* llm.inputs + const body = inputs.at(-1) as { metadata?: { altimate?: { message_id?: string } } } | undefined + const altimate = body?.metadata?.altimate + + expect(value).toBe("continue") + expect(altimate?.message_id).toBe(msg.id) + }), + { config: (url) => altimateProviderCfg(url) }, + ), +) +// altimate_change end + it.live("session.processor effect tests preserve text start time", () => provideTmpdirServerLegacy( ({ dir, llm }) =>