-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmodels.ts
More file actions
222 lines (206 loc) · 7.58 KB
/
Copy pathmodels.ts
File metadata and controls
222 lines (206 loc) · 7.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
import type { Model as ModelV2, Provider as ProviderV2 } from "@opencode-ai/sdk/v2";
import type { ProviderConfig } from "@opencode-ai/sdk";
import { AUTO_MODEL_ID } from "./constants.js";
import {
hasCapability,
type DiscoveredModel,
type DiscoveredPricing,
} from "./discovery.js";
/** The AI SDK package opencode drives an OpenAI-compatible upstream with. */
export const OPENAI_COMPATIBLE_NPM = "@ai-sdk/openai-compatible";
/**
* Capabilities assumed for the synthesized `auto` entry, used only while
* BitRouter's own catalog does not list it. They are deliberately the floor
* rather than the ceiling of what the route can reach: `auto` may land on any
* model in the tier ladder, and the two wrong answers do not cost the same.
* Under-claiming compacts a session earlier than it needed to; over-claiming
* sends a request the chosen model rejects outright, mid-turn. A catalog that
* lists `auto` replaces every one of these with the served value.
*/
const AUTO_CONTEXT = 128_000;
const AUTO_MAX_TOKENS = 16_384;
const DEFAULT_CONTEXT = 128_000;
const DEFAULT_MAX_TOKENS = 4096;
type Modality = "text" | "audio" | "image" | "video" | "pdf";
const MODALITIES: readonly Modality[] = ["text", "audio", "image", "video", "pdf"];
/**
* Capability tokens that imply an input modality, for a plane that advertises
* the capability but leaves `input_modalities` empty.
*/
const MODALITY_CAPABILITIES: ReadonlyArray<readonly [string, Modality]> = [
["image_input", "image"],
["audio_input", "audio"],
["video_input", "video"],
["file_input", "pdf"],
];
function normalizeModalities(raw: string[] | undefined, fallback: Modality[]): Modality[] {
const kept = (raw ?? []).filter((m): m is Modality =>
(MODALITIES as readonly string[]).includes(m),
);
return kept.length > 0 ? kept : fallback;
}
/** Input modalities, taking the declared list and the capability tokens together. */
function inputModalities(m: DiscoveredModel): Modality[] {
const declared = normalizeModalities(m.input_modalities, ["text"]);
const merged = new Set<Modality>(declared);
for (const [token, modality] of MODALITY_CAPABILITIES) {
if (hasCapability(m, token)) merged.add(modality);
}
return MODALITIES.filter((x) => merged.has(x));
}
function outputModalities(m: DiscoveredModel): Modality[] {
return normalizeModalities(m.output_modalities, ["text"]);
}
function modalityFlags(list: Modality[]): Record<Modality, boolean> {
return {
text: list.includes("text"),
audio: list.includes("audio"),
image: list.includes("image"),
video: list.includes("video"),
pdf: list.includes("pdf"),
};
}
/**
* Flatten BitRouter's nested per-million rates into the flat per-million shape
* opencode carries. Both sides are already per-million, so this is a reshape
* and not a conversion. An undeclared rate reads as 0 — "not priced here" —
* which is what opencode shows for a model whose cost it does not know.
*/
export function toCost(pricing: DiscoveredPricing | undefined): {
input: number;
output: number;
cache_read: number;
cache_write: number;
} {
const input = pricing?.input_tokens ?? {};
const output = pricing?.output_tokens ?? {};
return {
input: input.no_cache ?? 0,
output: output.text ?? 0,
cache_read: input.cache_read ?? 0,
cache_write: input.cache_write ?? 0,
};
}
/** Whether the model advertises extended reasoning. */
function isReasoning(m: DiscoveredModel): boolean {
return hasCapability(m, "reasoning");
}
/**
* Whether the model can call tools. Absent capability tokens mean the plane
* did not say — the local daemon never does — and a coding agent is unusable
* against a model it believes cannot call tools, so silence reads as yes.
* Cloud, which does advertise the token, is taken at its word.
*/
function isToolCall(m: DiscoveredModel): boolean {
if (!Array.isArray(m.capabilities) || m.capabilities.length === 0) return true;
return m.capabilities.includes("tools");
}
/**
* Map a `/v1/models` entry to the model shape opencode accepts inside a
* `provider.<id>.models` config block. Used by the `config` hook, which seeds
* the catalog at load time.
*/
export function toConfigModel(
m: DiscoveredModel,
): NonNullable<ProviderConfig["models"]>[string] {
const input = inputModalities(m);
const output = outputModalities(m);
return {
id: m.id,
name: m.name ?? m.id,
reasoning: isReasoning(m),
tool_call: isToolCall(m),
attachment: input.includes("image") || input.includes("pdf"),
temperature: true,
cost: toCost(m.pricing),
limit: {
context: m.max_input_tokens ?? DEFAULT_CONTEXT,
output: m.max_output_tokens ?? DEFAULT_MAX_TOKENS,
},
modalities: { input, output },
status: "active",
};
}
/**
* Map a `/v1/models` entry to opencode's fully-materialized `Model`. Used by the
* `provider.models` hook, which refreshes the catalog after authentication.
*
* `api` describes how opencode reaches the model; it is inherited from a model
* the provider already carries when there is one, so a user override in
* `opencode.json` is not silently discarded.
*/
export function toRuntimeModel(
m: DiscoveredModel,
provider: Pick<ProviderV2, "id" | "models" | "options">,
): ModelV2 {
const input = inputModalities(m);
const output = outputModalities(m);
const inherited = Object.values(provider.models ?? {})[0]?.api;
const baseURL = provider.options?.baseURL;
const cost = toCost(m.pricing);
return {
id: m.id,
providerID: provider.id,
api: inherited ?? {
id: provider.id,
url: typeof baseURL === "string" ? baseURL : "",
npm: OPENAI_COMPATIBLE_NPM,
},
name: m.name ?? m.id,
capabilities: {
temperature: true,
reasoning: isReasoning(m),
attachment: input.includes("image") || input.includes("pdf"),
toolcall: isToolCall(m),
input: modalityFlags(input),
output: modalityFlags(output),
interleaved: false,
},
cost: {
input: cost.input,
output: cost.output,
cache: { read: cost.cache_read, write: cost.cache_write },
},
limit: {
context: m.max_input_tokens ?? DEFAULT_CONTEXT,
output: m.max_output_tokens ?? DEFAULT_MAX_TOKENS,
},
status: "active",
options: {},
headers: {},
release_date: "",
};
}
/**
* The synthesized `auto` entry, used only while BitRouter's catalog does not
* list one itself. Tool calling is on because the route exists to serve a
* coding agent; the capacities are the conservative floor documented above.
*/
export function autoModel(): DiscoveredModel {
return {
id: AUTO_MODEL_ID,
name: "BitRouter Auto",
description: "Let BitRouter choose the model for each request.",
max_input_tokens: AUTO_CONTEXT,
max_output_tokens: AUTO_MAX_TOKENS,
input_modalities: ["text", "image"],
output_modalities: ["text"],
capabilities: ["tools", "reasoning"],
};
}
/**
* Put the auto route at the head of the catalog, synthesizing it when the
* gateway does not serve one yet.
*
* A served entry still wins if one ever appears, though none does today:
* `bitrouter/` is resolved before any provider lookup, and BitRouter's registry
* validator refuses catalog models under it, so the entry has to come from
* here. The check costs nothing and keeps the placeholder from shadowing a
* future one. Order matters because the head
* of this list is what a surface offers first.
*/
export function withAutoModel(discovered: DiscoveredModel[]): DiscoveredModel[] {
const served = discovered.find((m) => m.id === AUTO_MODEL_ID);
const rest = discovered.filter((m) => m.id !== AUTO_MODEL_ID);
return [served ?? autoModel(), ...rest];
}