Skip to content

Commit f97b3ff

Browse files
authored
Merge pull request #23 from mammouth-ai/mammouth-code-reasoning-effort-n2846
fixed xhigh and max reasoning efforts for Mammouth API models
2 parents f4d00ad + 3fc6f35 commit f97b3ff

7 files changed

Lines changed: 205 additions & 43 deletions

File tree

packages/core/src/models-dev.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export const Model = Schema.Struct({
5151
release_date: Schema.String,
5252
attachment: Schema.Boolean,
5353
reasoning: Schema.Boolean,
54+
efforts: Schema.optional(Schema.Array(Schema.String)),
5455
temperature: Schema.Boolean,
5556
tool_call: Schema.Boolean,
5657
interleaved: Schema.optional(
@@ -174,6 +175,20 @@ function humanizeModelName(name: string): string {
174175
return parts.map((p) => specialCases[p.toLowerCase()] ?? capitalize(p)).join(" ")
175176
}
176177

178+
// litellm's supports_reasoning implies the default low/medium/high effort
179+
// range; none/minimal and the exceptional xhigh/max tiers are opted into
180+
// per-model via their own supports_* flags in the litellm config.
181+
function reasoningEfforts(info: any): string[] | undefined {
182+
if (!info.supports_reasoning) return undefined
183+
const efforts: string[] = []
184+
if (info.supports_none_reasoning_effort) efforts.push("none")
185+
if (info.supports_minimal_reasoning_effort) efforts.push("minimal")
186+
efforts.push("low", "medium", "high")
187+
if (info.supports_xhigh_reasoning_effort) efforts.push("xhigh")
188+
if (info.supports_max_reasoning_effort) efforts.push("max")
189+
return efforts
190+
}
191+
177192
function transformApiResponse(data: any): Model[] {
178193
if (!data?.data || !Array.isArray(data.data)) return []
179194

@@ -196,6 +211,7 @@ function transformApiResponse(data: any): Model[] {
196211
release_date: "",
197212
attachment: info.supports_vision || info.supports_pdf_input || false,
198213
reasoning: info.supports_reasoning || false,
214+
efforts: reasoningEfforts(info),
199215
temperature: true,
200216
tool_call: info.supports_function_calling || info.supports_tool_choice || false,
201217
cost: {

packages/core/src/plugin/models-dev.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,21 @@ function cost(input: ModelsDev.Model["cost"]) {
3838
}
3939

4040
function variants(model: ModelsDev.Model) {
41-
return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => ({
41+
const result = Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => ({
4242
id: ModelV2.VariantID.make(id),
4343
headers: { ...(item.provider?.headers ?? {}) },
4444
body: { ...(item.provider?.body ?? {}) },
4545
}))
46+
const ids = new Set<string>(result.map((item) => item.id))
47+
for (const effort of model.efforts ?? []) {
48+
if (ids.has(effort)) continue
49+
result.push({
50+
id: ModelV2.VariantID.make(effort),
51+
headers: {},
52+
body: { reasoning_effort: effort },
53+
})
54+
}
55+
return result
4656
}
4757

4858
export const ModelsDevPlugin = define({

packages/core/test/plugin/fixtures/models-dev.json

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,36 @@
22
"acme": {
33
"id": "acme",
44
"name": "Acme",
5-
"env": ["ACME_API_KEY"],
6-
"models": {}
5+
"env": [
6+
"ACME_API_KEY"
7+
],
8+
"models": {
9+
"deluxe": {
10+
"id": "deluxe",
11+
"name": "Deluxe",
12+
"release_date": "2025-01-01",
13+
"attachment": false,
14+
"reasoning": true,
15+
"efforts": [
16+
"low",
17+
"medium",
18+
"high",
19+
"xhigh",
20+
"max"
21+
],
22+
"temperature": true,
23+
"tool_call": true,
24+
"limit": {
25+
"context": 200000,
26+
"output": 64000
27+
}
28+
}
29+
}
730
},
831
"local": {
932
"id": "local",
1033
"name": "Local",
1134
"env": [],
1235
"models": {}
1336
}
14-
}
37+
}

packages/core/test/plugin/models-dev.test.ts

Lines changed: 80 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import { Credential } from "@opencode-ai/core/credential"
77
import { EventV2 } from "@opencode-ai/core/event"
88
import { Flag } from "@opencode-ai/core/flag/flag"
99
import { Location } from "@opencode-ai/core/location"
10+
import { ModelV2 } from "@opencode-ai/core/model"
1011
import { ModelsDev } from "@opencode-ai/core/models-dev"
1112
import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
1213
import { Policy } from "@opencode-ai/core/policy"
14+
import { ProviderV2 } from "@opencode-ai/core/provider"
1315
import { AbsolutePath } from "@opencode-ai/core/schema"
1416
import { location } from "../fixture/location"
1517
import { testEffect } from "../lib/effect"
@@ -29,48 +31,87 @@ const catalog = Catalog.layer.pipe(
2931
const layer = Layer.mergeAll(catalog.pipe(Layer.provide(connections)), integrations, connections, events, locationLayer)
3032
const it = testEffect(layer)
3133

34+
const withFixture = <A, E, R>(use: Effect.Effect<A, E, R>) =>
35+
Effect.acquireUseRelease(
36+
Effect.sync(() => {
37+
const previous = {
38+
path: Flag.OPENCODE_MODELS_PATH,
39+
disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
40+
}
41+
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json")
42+
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
43+
return previous
44+
}),
45+
() => use.pipe(Effect.provide(ModelsDev.defaultLayer)),
46+
(previous) =>
47+
Effect.sync(() => {
48+
Flag.OPENCODE_MODELS_PATH = previous.path
49+
Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled
50+
}),
51+
)
52+
3253
describe("ModelsDevPlugin", () => {
3354
it.effect("registers key methods for providers with environment variables", () =>
34-
Effect.acquireUseRelease(
35-
Effect.sync(() => {
36-
const previous = {
37-
path: Flag.OPENCODE_MODELS_PATH,
38-
disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
39-
}
40-
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json")
41-
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
42-
return previous
55+
withFixture(
56+
Effect.gen(function* () {
57+
const integrations = yield* Integration.Service
58+
const catalog = yield* Catalog.Service
59+
yield* ModelsDevPlugin.effect(
60+
host({
61+
catalog: catalogHost(catalog),
62+
integration: integrationHost(integrations),
63+
}),
64+
)
65+
expect(yield* integrations.list()).toEqual([
66+
new Integration.Info({
67+
id: Integration.ID.make("acme"),
68+
name: "Acme",
69+
methods: [
70+
{ type: "key" },
71+
{
72+
type: "env",
73+
names: ["ACME_API_KEY"],
74+
},
75+
],
76+
connections: [],
77+
}),
78+
new Integration.Info({
79+
id: Integration.ID.make("mammouth-ai"),
80+
name: "Mammouth AI",
81+
methods: [
82+
{ type: "key" },
83+
{
84+
type: "env",
85+
names: ["MAMMOUTH_API_KEY"],
86+
},
87+
],
88+
connections: [],
89+
}),
90+
])
91+
}),
92+
),
93+
)
94+
95+
it.effect("maps explicit model efforts to reasoning_effort variants", () =>
96+
withFixture(
97+
Effect.gen(function* () {
98+
const integrations = yield* Integration.Service
99+
const catalog = yield* Catalog.Service
100+
yield* ModelsDevPlugin.effect(
101+
host({
102+
catalog: catalogHost(catalog),
103+
integration: integrationHost(integrations),
104+
}),
105+
)
106+
const model = yield* catalog.model.get(ProviderV2.ID.make("acme"), ModelV2.ID.make("deluxe"))
107+
expect(model?.variants).toEqual([
108+
expect.objectContaining({ id: "low", body: { reasoning_effort: "low" } }),
109+
expect.objectContaining({ id: "medium", body: { reasoning_effort: "medium" } }),
110+
expect.objectContaining({ id: "high", body: { reasoning_effort: "high" } }),
111+
expect.objectContaining({ id: "xhigh", body: { reasoning_effort: "xhigh" } }),
112+
expect.objectContaining({ id: "max", body: { reasoning_effort: "max" } }),
113+
])
43114
}),
44-
() =>
45-
Effect.gen(function* () {
46-
const integrations = yield* Integration.Service
47-
const catalog = yield* Catalog.Service
48-
yield* ModelsDevPlugin.effect(
49-
host({
50-
catalog: catalogHost(catalog),
51-
integration: integrationHost(integrations),
52-
}),
53-
)
54-
expect(yield* integrations.list()).toEqual([
55-
new Integration.Info({
56-
id: Integration.ID.make("acme"),
57-
name: "Acme",
58-
methods: [
59-
{ type: "key" },
60-
{
61-
type: "env",
62-
names: ["ACME_API_KEY"],
63-
},
64-
],
65-
connections: [],
66-
}),
67-
])
68-
}).pipe(Effect.provide(ModelsDev.defaultLayer)),
69-
(previous) =>
70-
Effect.sync(() => {
71-
Flag.OPENCODE_MODELS_PATH = previous.path
72-
Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled
73-
}),
74115
),
75116
)
76117
})

packages/opencode/src/provider/provider.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,6 +1052,7 @@ export const Model = Schema.Struct({
10521052
options: Schema.Record(Schema.String, Schema.Any),
10531053
headers: Schema.Record(Schema.String, Schema.String),
10541054
release_date: Schema.String,
1055+
efforts: optionalOmitUndefined(Schema.Array(Schema.String)),
10551056
variants: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))),
10561057
}).annotate({ identifier: "Model" })
10571058
export type Model = Types.DeepMutable<Schema.Schema.Type<typeof Model>>
@@ -1251,6 +1252,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
12511252
interleaved: model.interleaved ?? false,
12521253
},
12531254
release_date: model.release_date ?? "",
1255+
efforts: model.efforts ? [...model.efforts] : undefined,
12541256
variants: {},
12551257
}
12561258

@@ -1497,6 +1499,7 @@ export const layer = Layer.effect(
14971499
headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
14981500
family: model.family ?? existingModel?.family ?? "",
14991501
release_date: model.release_date ?? existingModel?.release_date ?? "",
1502+
efforts: existingModel?.efforts,
15001503
variants: {},
15011504
}
15021505
const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})

packages/opencode/src/provider/transform.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,13 @@ function googleThinkingVariants(model: Provider.Model): Record<string, Record<st
665665
export function variants(model: Provider.Model): Record<string, Record<string, any>> {
666666
if (!model.capabilities.reasoning) return {}
667667

668+
// Models fetched from the Mammouth API carry an explicit effort list derived
669+
// from litellm's supports_* flags; litellm translates reasoning_effort to
670+
// each upstream's native control, so it overrides the heuristics below.
671+
if (model.efforts?.length) {
672+
return Object.fromEntries(model.efforts.map((effort) => [effort, { reasoningEffort: effort }]))
673+
}
674+
668675
const id = model.id.toLowerCase()
669676
const glm52 = ["glm-5.2", "glm-5-2", "glm-5p2"].some(
670677
(name) => id.includes(name) || model.api.id.toLowerCase().includes(name),

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2820,6 +2820,68 @@ describe("ProviderTransform.variants", () => {
28202820
expect(result).toEqual({})
28212821
})
28222822

2823+
test("explicit efforts override npm heuristics", () => {
2824+
const model = createMockModel({
2825+
id: "mammouth-ai/claude-opus-4-8",
2826+
providerID: "mammouth-ai",
2827+
api: {
2828+
id: "claude-opus-4-8",
2829+
url: "https://api.mammouth.ai/v1",
2830+
npm: "@ai-sdk/openai-compatible",
2831+
},
2832+
efforts: ["low", "medium", "high", "xhigh", "max"],
2833+
})
2834+
expect(ProviderTransform.variants(model)).toEqual({
2835+
low: { reasoningEffort: "low" },
2836+
medium: { reasoningEffort: "medium" },
2837+
high: { reasoningEffort: "high" },
2838+
xhigh: { reasoningEffort: "xhigh" },
2839+
max: { reasoningEffort: "max" },
2840+
})
2841+
})
2842+
2843+
test("explicit efforts override the no-variant id blocklist", () => {
2844+
const model = createMockModel({
2845+
id: "mammouth-ai/kimi-k2.6",
2846+
providerID: "mammouth-ai",
2847+
api: {
2848+
id: "kimi-k2.6",
2849+
url: "https://api.mammouth.ai/v1",
2850+
npm: "@ai-sdk/openai-compatible",
2851+
},
2852+
efforts: ["low", "medium", "high"],
2853+
})
2854+
expect(ProviderTransform.variants(model)).toEqual({
2855+
low: { reasoningEffort: "low" },
2856+
medium: { reasoningEffort: "medium" },
2857+
high: { reasoningEffort: "high" },
2858+
})
2859+
})
2860+
2861+
test("explicit efforts still require reasoning capability", () => {
2862+
const model = createMockModel({
2863+
capabilities: { reasoning: false },
2864+
efforts: ["low", "medium", "high"],
2865+
})
2866+
expect(ProviderTransform.variants(model)).toEqual({})
2867+
})
2868+
2869+
test("empty efforts list falls through to npm heuristics", () => {
2870+
const model = createMockModel({
2871+
api: {
2872+
id: "test-model",
2873+
url: "https://api.test.com",
2874+
npm: "@ai-sdk/openai-compatible",
2875+
},
2876+
efforts: [],
2877+
})
2878+
expect(ProviderTransform.variants(model)).toEqual({
2879+
low: { reasoningEffort: "low" },
2880+
medium: { reasoningEffort: "medium" },
2881+
high: { reasoningEffort: "high" },
2882+
})
2883+
})
2884+
28232885
test("deepseek returns empty object", () => {
28242886
const model = createMockModel({
28252887
id: "deepseek/deepseek-chat",

0 commit comments

Comments
 (0)