Skip to content
Draft
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
2 changes: 2 additions & 0 deletions packages/opencode/src/altimate/enhance-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ export async function enhancePrompt(text: string): Promise<string> {
abort: controller.signal,
sessionID: user.sessionID,
retries: 2,
// altimate_change — routing hint (Phase 0)
taskKind: "enhance",
messages: [
{
role: "user",
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/altimate/free/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/altimate/review/ai-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ export async function runAiReview(input: AiReviewInput): Promise<Finding[]> {
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) {
Expand Down
26 changes: 22 additions & 4 deletions packages/opencode/src/altimate/skill-selector.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<Skill.Info[]> {
const startTime = Date.now()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<string[]> {
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<string[]> {
// 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,
Expand Down Expand Up @@ -183,6 +199,8 @@ async function runWithLLM(prompt: string, validNames: string[]): Promise<string[
abort: controller.signal,
sessionID: user.sessionID,
retries: 1,
// altimate_change — routing hint (Phase 0)
taskKind: "skill_select",
messages: [
{
role: "user",
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1595,6 +1595,44 @@ export namespace Provider {
release_date: "2026-08-29",
variants: {},
},
// altimate_change start — routing hint (Phase 0): selectable "auto" alias, same shape/limits
// 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().
//
// 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] = {
id: ProviderID.make(FreeTier.PROVIDER_ID),
Expand Down
40 changes: 37 additions & 3 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,40 @@ export namespace ProviderTransform {
}
// altimate_change end

// altimate_change start \u2014 routing hint (Phase 0): shared task-kind enum + the "which model id is
// one of our managed hosted aliases" check used by the sampling/reasoning special-casing below.
// "altimate-auto" resolves server-side to the same served model as "altimate-base" today, so it
// needs identical client-side tuning until per-request routing actually differentiates them.
export type AltimateTaskKind =
| "main"
| "subagent"
| "title"
| "summary"
| "compaction"
| "skill_select"
| "enhance"
| "review"
| "project_copy"
| "other"

// Exact match, not substring: "altimate-base" / "altimate-auto" are the two hosted aliases; a
// substring check would also match an unrelated future model id that merely contains one of
// these as a fragment.
const ALTIMATE_MANAGED_MODEL_IDS: ReadonlySet<string> = new Set(["altimate-base", "altimate-auto"])

export function isAltimateManagedModel(id: string): boolean {
return ALTIMATE_MANAGED_MODEL_IDS.has(id)
}

// 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

// Maps npm package to the key the AI SDK expects for providerOptions
function sdkKey(npm: string): string | undefined {
switch (npm) {
Expand Down Expand Up @@ -596,7 +630,7 @@ export namespace ProviderTransform {
if (id.includes("qwen")) return 0.55
// altimate_change start — the model served behind this stable alias needs the same tuning as
// the row above; the gateway does not force sampling params on its own.
if (id.includes("altimate-base")) return 0.55
if (isAltimateManagedModel(id)) return 0.55
// altimate_change end
if (id.includes("claude")) return undefined
if (id.includes("gemini")) return 1.0
Expand All @@ -617,7 +651,7 @@ export namespace ProviderTransform {
const id = model.id.toLowerCase()
if (id.includes("qwen")) return 1
// altimate_change start — same served-model reasoning as temperature() above.
if (id.includes("altimate-base")) return 1
if (isAltimateManagedModel(id)) return 1
// altimate_change end
if (["minimax-m2", "gemini", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) {
return 0.95
Expand Down Expand Up @@ -825,7 +859,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec
model,
sessionID,
retries: 2,
// altimate_change start — routing hint (Phase 0)
taskKind: "project_copy",
// altimate_change end
messages: [{ role: "user", content: `Generate a short 2-3 word name that describes this task:\n${text}` }],
})
.pipe(
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ import { LLMAISDK } from "./llm/ai-sdk"
export namespace LLM {
const log = Log.create({ service: "llm" })
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
// altimate_change start — routing hint (Phase 0): mirrors the gateway's `agent` allowlist
// exactly (docs/internal/2026-09-22-gateway-model-routing-research.md) so an invalid name is
// dropped client-side instead of silently reaching the gateway as a key it would drop anyway.
const ALTIMATE_HINT_AGENT_RE = /^[a-z][a-z0-9_-]{0,31}$/
// altimate_change end

export type StreamInput = {
user: MessageV2.User
Expand All @@ -57,6 +62,22 @@ export namespace LLM {
tools: Record<string, Tool>
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"
// The id the hint's `message_id` reports, so it joins the same `generation` telemetry event
// (session/processor.ts's Telemetry.track({ type: "generation", message_id: ... })) on the
// same id. `session/processor.ts` sets this to `assistantMessage.id` for every turn that goes
// through it (main, subagent, summary, compaction). Call sites with no processor turn at all
// (title, skill-selector, enhance-prompt, ai-review, project-copy) set it explicitly to "the
// message this call is about" when one exists (title does), or leave it unset — `message_id`
// is omitted from the hint entirely rather than falling back to a throwaway synthetic id that
// nothing else could ever join against.
messageId?: string
// altimate_change end
}

export type StreamOutput = StreamTextResult<ToolSet, never>
Expand Down Expand Up @@ -271,6 +292,37 @@ 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<string, unknown> = {
task_kind: input.taskKind ?? "other",
// 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),
...(input.messageId ? { message_id: input.messageId } : {}),
}
if (input.minTier) altimateHint.min_tier = input.minTier
requestOptions["metadata"] = {
...(requestOptions["metadata"] as Record<string, unknown> | undefined),
altimate: altimateHint,
}
}
// altimate_change end

return streamText({
onError(error) {
l.error("stream error", {
Expand Down
8 changes: 8 additions & 0 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1442,7 +1442,9 @@ 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 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
? ""
Expand Down Expand Up @@ -1544,6 +1546,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
Expand Down Expand Up @@ -4079,6 +4087,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
system: [],
small: true,
tools: {},
// 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
Expand Down
11 changes: 9 additions & 2 deletions packages/opencode/src/session/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
Loading
Loading