Skip to content

Commit b060066

Browse files
authored
feat: add support for fast modes for claude and gpt models (that support it) (#21706)
1 parent 581a769 commit b060066

4 files changed

Lines changed: 152 additions & 38 deletions

File tree

packages/opencode/src/plugin/codex.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -376,9 +376,9 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
376376
"gpt-5.4",
377377
"gpt-5.4-mini",
378378
])
379-
for (const modelId of Object.keys(provider.models)) {
379+
for (const [modelId, model] of Object.entries(provider.models)) {
380380
if (modelId.includes("codex")) continue
381-
if (allowedModels.has(modelId)) continue
381+
if (allowedModels.has(model.api.id)) continue
382382
delete provider.models[modelId]
383383
}
384384

packages/opencode/src/provider/models.ts

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,27 @@ export namespace ModelsDev {
2222
)
2323
const ttl = 5 * 60 * 1000
2424

25+
type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[]
26+
27+
const JsonValue: z.ZodType<JsonValue> = z.lazy(() =>
28+
z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(JsonValue), z.record(z.string(), JsonValue)]),
29+
)
30+
31+
const Cost = z.object({
32+
input: z.number(),
33+
output: z.number(),
34+
cache_read: z.number().optional(),
35+
cache_write: z.number().optional(),
36+
context_over_200k: z
37+
.object({
38+
input: z.number(),
39+
output: z.number(),
40+
cache_read: z.number().optional(),
41+
cache_write: z.number().optional(),
42+
})
43+
.optional(),
44+
})
45+
2546
export const Model = z.object({
2647
id: z.string(),
2748
name: z.string(),
@@ -41,22 +62,7 @@ export namespace ModelsDev {
4162
.strict(),
4263
])
4364
.optional(),
44-
cost: z
45-
.object({
46-
input: z.number(),
47-
output: z.number(),
48-
cache_read: z.number().optional(),
49-
cache_write: z.number().optional(),
50-
context_over_200k: z
51-
.object({
52-
input: z.number(),
53-
output: z.number(),
54-
cache_read: z.number().optional(),
55-
cache_write: z.number().optional(),
56-
})
57-
.optional(),
58-
})
59-
.optional(),
65+
cost: Cost.optional(),
6066
limit: z.object({
6167
context: z.number(),
6268
input: z.number().optional(),
@@ -68,7 +74,24 @@ export namespace ModelsDev {
6874
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
6975
})
7076
.optional(),
71-
experimental: z.boolean().optional(),
77+
experimental: z
78+
.object({
79+
modes: z
80+
.record(
81+
z.string(),
82+
z.object({
83+
cost: Cost.optional(),
84+
provider: z
85+
.object({
86+
body: z.record(z.string(), JsonValue).optional(),
87+
headers: z.record(z.string(), z.string()).optional(),
88+
})
89+
.optional(),
90+
}),
91+
)
92+
.optional(),
93+
})
94+
.optional(),
7295
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
7396
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
7497
})

packages/opencode/src/provider/provider.ts

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,28 @@ export namespace Provider {
926926

927927
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Provider") {}
928928

929+
function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
930+
const result: Model["cost"] = {
931+
input: c?.input ?? 0,
932+
output: c?.output ?? 0,
933+
cache: {
934+
read: c?.cache_read ?? 0,
935+
write: c?.cache_write ?? 0,
936+
},
937+
}
938+
if (c?.context_over_200k) {
939+
result.experimentalOver200K = {
940+
cache: {
941+
read: c.context_over_200k.cache_read ?? 0,
942+
write: c.context_over_200k.cache_write ?? 0,
943+
},
944+
input: c.context_over_200k.input,
945+
output: c.context_over_200k.output,
946+
}
947+
}
948+
return result
949+
}
950+
929951
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
930952
const m: Model = {
931953
id: ModelID.make(model.id),
@@ -940,24 +962,7 @@ export namespace Provider {
940962
status: model.status ?? "active",
941963
headers: {},
942964
options: {},
943-
cost: {
944-
input: model.cost?.input ?? 0,
945-
output: model.cost?.output ?? 0,
946-
cache: {
947-
read: model.cost?.cache_read ?? 0,
948-
write: model.cost?.cache_write ?? 0,
949-
},
950-
experimentalOver200K: model.cost?.context_over_200k
951-
? {
952-
cache: {
953-
read: model.cost.context_over_200k.cache_read ?? 0,
954-
write: model.cost.context_over_200k.cache_write ?? 0,
955-
},
956-
input: model.cost.context_over_200k.input,
957-
output: model.cost.context_over_200k.output,
958-
}
959-
: undefined,
960-
},
965+
cost: cost(model.cost),
961966
limit: {
962967
context: model.limit.context,
963968
input: model.limit.input,
@@ -994,13 +999,31 @@ export namespace Provider {
994999
}
9951000

9961001
export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
1002+
const models: Record<string, Model> = {}
1003+
for (const [key, model] of Object.entries(provider.models)) {
1004+
models[key] = fromModelsDevModel(provider, model)
1005+
for (const [mode, opts] of Object.entries(model.experimental?.modes ?? {})) {
1006+
const id = `${model.id}-${mode}`
1007+
const m = fromModelsDevModel(provider, model)
1008+
m.id = ModelID.make(id)
1009+
m.name = `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`
1010+
if (opts.cost) m.cost = mergeDeep(m.cost, cost(opts.cost))
1011+
// convert body params to camelCase for ai sdk compatibility
1012+
if (opts.provider?.body)
1013+
m.options = Object.fromEntries(
1014+
Object.entries(opts.provider.body).map(([k, v]) => [k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()), v]),
1015+
)
1016+
if (opts.provider?.headers) m.headers = opts.provider.headers
1017+
models[id] = m
1018+
}
1019+
}
9971020
return {
9981021
id: ProviderID.make(provider.id),
9991022
source: "custom",
10001023
name: provider.name,
10011024
env: provider.env ?? [],
10021025
options: {},
1003-
models: mapValues(provider.models, (model) => fromModelsDevModel(provider, model)),
1026+
models,
10041027
}
10051028
}
10061029

packages/opencode/test/provider/provider.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { tmpdir } from "../fixture/fixture"
66
import { Global } from "../../src/global"
77
import { Instance } from "../../src/project/instance"
88
import { Plugin } from "../../src/plugin/index"
9+
import { ModelsDev } from "../../src/provider/models"
910
import { Provider } from "../../src/provider/provider"
1011
import { ProviderID, ModelID } from "../../src/provider/schema"
1112
import { Filesystem } from "../../src/util/filesystem"
@@ -1823,6 +1824,73 @@ test("custom model inherits api.url from models.dev provider", async () => {
18231824
})
18241825
})
18251826

1827+
test("mode cost preserves over-200k pricing from base model", () => {
1828+
const provider = {
1829+
id: "openai",
1830+
name: "OpenAI",
1831+
env: [],
1832+
api: "https://api.openai.com/v1",
1833+
models: {
1834+
"gpt-5.4": {
1835+
id: "gpt-5.4",
1836+
name: "GPT-5.4",
1837+
family: "gpt",
1838+
release_date: "2026-03-05",
1839+
attachment: true,
1840+
reasoning: true,
1841+
temperature: false,
1842+
tool_call: true,
1843+
cost: {
1844+
input: 2.5,
1845+
output: 15,
1846+
cache_read: 0.25,
1847+
context_over_200k: {
1848+
input: 5,
1849+
output: 22.5,
1850+
cache_read: 0.5,
1851+
},
1852+
},
1853+
limit: {
1854+
context: 1_050_000,
1855+
input: 922_000,
1856+
output: 128_000,
1857+
},
1858+
experimental: {
1859+
modes: {
1860+
fast: {
1861+
cost: {
1862+
input: 5,
1863+
output: 30,
1864+
cache_read: 0.5,
1865+
},
1866+
provider: {
1867+
body: {
1868+
service_tier: "priority",
1869+
},
1870+
},
1871+
},
1872+
},
1873+
},
1874+
},
1875+
},
1876+
} as ModelsDev.Provider
1877+
1878+
const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"]
1879+
expect(model.cost.input).toEqual(5)
1880+
expect(model.cost.output).toEqual(30)
1881+
expect(model.cost.cache.read).toEqual(0.5)
1882+
expect(model.cost.cache.write).toEqual(0)
1883+
expect(model.options["serviceTier"]).toEqual("priority")
1884+
expect(model.cost.experimentalOver200K).toEqual({
1885+
input: 5,
1886+
output: 22.5,
1887+
cache: {
1888+
read: 0.5,
1889+
write: 0,
1890+
},
1891+
})
1892+
})
1893+
18261894
test("model variants are generated for reasoning models", async () => {
18271895
await using tmp = await tmpdir({
18281896
init: async (dir) => {

0 commit comments

Comments
 (0)