Skip to content

Commit c500750

Browse files
AbirAbbasclaude
andauthored
feat(sdk/python): model#variant reasoning-effort support across harness providers (#801)
* feat(sdk/python): split_model_variant + resolve_model_and_variant harness helpers Parse the 'provider/model#variant' model-string syntax so a reasoning-effort variant can travel through config surfaces that only hold a model string. An explicit options['variant'] wins over the suffix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(sdk/python): opencode provider passes --variant reasoning effort 'model#variant' (or an explicit variant option) now maps to opencode run's --variant flag; the -m flag and cost/metrics reporting use the base model id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(sdk/python): codex provider passes -m and model_reasoning_effort codex exec previously received no model at all — options['model'] was only used for cost estimation, so every run used the CLI's own default. Pass -m <model>, and map a '#variant' suffix to -c model_reasoning_effort=<v>. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk/python): claude/gemini providers strip #variant model suffix Neither runtime has a reasoning-effort control; strip the suffix so the underlying CLI/SDK still receives a valid model id instead of 'model#variant'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: model selection and #variant reasoning-effort syntax for harness providers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(sdk/go): model#variant reasoning-effort parity in harness providers Mirrors the Python SDK: SplitModelVariant + Options.Variant (explicit field wins over the suffix), opencode passes --variant, codex adds -c model_reasoning_effort, claudecode/gemini strip the suffix. The OpenRouter-attribution overlay and usage recording key off the base model so a suffix neither defeats the prefix match nor pollutes attribution. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(sdk/typescript): model#variant reasoning-effort parity in harness providers Mirrors the Python SDK: splitModelVariant/resolveModelAndVariant helper, variant?: string through HarnessConfig/HarnessOptions, opencode passes --variant, codex now passes -m at all (it previously sent no model to the CLI — same bug the Python provider had) plus -c model_reasoning_effort, claude/gemini strip the suffix. Attribution overlay and usage metrics key off the base model. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a6afa87 commit c500750

42 files changed

Lines changed: 1092 additions & 39 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/harness-providers.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,31 @@ The extras install Python wrappers. They do not replace the runtime preflight:
2323
Gemini is CLI-only, and Codex or OpenCode may still require a separately
2424
available executable depending on the wrapper and platform.
2525

26+
## Model selection and reasoning-effort variants
27+
28+
Every provider accepts a `model` option on `.harness()` calls. The model string
29+
may carry a reasoning-effort variant after a `#` separator:
30+
31+
```python
32+
result = await app.harness(
33+
task,
34+
provider="opencode",
35+
model="openrouter/z-ai/glm-5.2#high",
36+
)
37+
```
38+
39+
An explicit `variant="high"` keyword wins over the suffix. Per provider:
40+
41+
| Provider | Model flag | Variant handling |
42+
| --- | --- | --- |
43+
| OpenCode | `-m <model>` | `--variant <v>` (provider-specific effort, e.g. `high`, `max`, `minimal`) |
44+
| Codex | `-m <model>` | `-c model_reasoning_effort=<v>` |
45+
| Claude Code | SDK `model` option | No effort control — variant is dropped with a debug log |
46+
| Gemini | `-m <model>` | No effort control — variant is dropped |
47+
48+
The `#` separator is safe in model ids: `:` belongs to OpenRouter suffixes like
49+
`:free`, and `@` to Vertex-style ids, but no provider uses `#`.
50+
2651
## Verify
2752

2853
Check selected providers in a container or CI job before any paid run:

sdk/go/agent/cost_tracker_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,4 +300,21 @@ func TestRecordHarnessUsage(t *testing.T) {
300300
assert.Equal(t, "openrouter/qwen/qwen3-coder", entries[0]["model"])
301301
assert.Equal(t, "openrouter", entries[0]["provider"])
302302
})
303+
304+
t.Run("model variant suffix is stripped for attribution", func(t *testing.T) {
305+
a := newAgentForTest(t)
306+
tracker := NewCostTracker()
307+
ctx := contextWithCostTracker(context.Background(), tracker)
308+
309+
a.recordHarnessUsage(ctx, &harness.Result{InputTokens: 1}, harness.Options{
310+
Provider: "opencode",
311+
Model: "openrouter/z-ai/glm-5.2#high",
312+
})
313+
314+
entries := tracker.Serialize()["entries"].([]map[string]any)
315+
require.Len(t, entries, 1)
316+
assert.Equal(t, "openrouter/z-ai/glm-5.2", entries[0]["model"],
317+
"usage must attribute to the BASE model, not the #variant-suffixed string")
318+
assert.Equal(t, "openrouter", entries[0]["provider"])
319+
})
303320
}

sdk/go/agent/harness.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,16 @@ type HarnessConfig struct {
1313
// Provider is the default provider: "claude-code", "codex", "gemini", or "opencode".
1414
Provider string
1515

16-
// Model is the default model identifier.
16+
// Model is the default model identifier. It may carry a
17+
// reasoning-effort variant after a "#" separator (e.g.
18+
// "openrouter/z-ai/glm-5.2#high").
1719
Model string
1820

21+
// Variant is the default provider-specific reasoning-effort variant
22+
// (e.g. "high", "minimal"). It wins over a "#variant" suffix on Model;
23+
// providers without an effort control drop it.
24+
Variant string
25+
1926
// MaxTurns is the default max agent iterations.
2027
MaxTurns int
2128

@@ -48,6 +55,7 @@ func (a *Agent) HarnessRunner() *harness.Runner {
4855
hc := a.cfg.HarnessConfig
4956
opts.Provider = hc.Provider
5057
opts.Model = hc.Model
58+
opts.Variant = hc.Variant
5159
opts.MaxTurns = hc.MaxTurns
5260
opts.PermissionMode = hc.PermissionMode
5361
opts.Env = hc.Env

sdk/go/agent/usage.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ func (a *Agent) recordHarnessUsage(ctx context.Context, result *harness.Result,
145145
if model == "" && a.cfg.HarnessConfig != nil {
146146
model = a.cfg.HarnessConfig.Model
147147
}
148+
// Attribute usage to the BASE model: a "#variant" reasoning-effort
149+
// suffix is a harness dispatch detail, not part of the model id (the
150+
// Python SDK likewise records the provider-reported base model).
151+
model, _ = harness.SplitModelVariant(model)
148152
if model == "" {
149153
model = provider
150154
}

sdk/go/harness/claudecode.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,13 @@ func (p *ClaudeCodeProvider) Execute(ctx context.Context, prompt string, options
4646
// stream-json ("--output-format=stream-json requires --verbose").
4747
cmd := []string{p.BinPath, "--print", "--output-format", "stream-json", "--verbose"}
4848

49-
if options.Model != "" {
50-
cmd = append(cmd, "--model", options.Model)
49+
// Strip any "#variant" reasoning-effort suffix so the CLI receives a
50+
// valid model id. claude-code has no effort flag, so the variant is
51+
// dropped (the Python provider logs this at debug level; this package
52+
// has no provider-level logger).
53+
modelValue, _ := options.resolveModelAndVariant()
54+
if modelValue != "" {
55+
cmd = append(cmd, "--model", modelValue)
5156
}
5257

5358
if options.MaxTurns > 0 {

sdk/go/harness/claudecode_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,35 @@ func TestClaudeCodeProvider_ParsesStreamJSONFixture(t *testing.T) {
145145
assert.Equal(t, 1, raw.Metrics.NumTurns)
146146
})
147147
}
148+
149+
// TestClaudeCodeProvider_StripsModelVariantSuffix mirrors the Python
150+
// test_execute_strips_model_variant_suffix: claude-code has no reasoning-
151+
// effort control, so a "#variant" suffix is stripped from --model (the CLI
152+
// must receive a valid model id) and the variant is dropped.
153+
func TestClaudeCodeProvider_StripsModelVariantSuffix(t *testing.T) {
154+
p := NewClaudeCodeProvider("claude")
155+
156+
var gotCmd []string
157+
p.runCLI = func(_ context.Context, cmd []string, _ map[string]string, _ string, _ int, _ []byte) (*CLIResult, error) {
158+
gotCmd = append([]string(nil), cmd...)
159+
return &CLIResult{
160+
Stdout: `{"type":"result","result":"OK","session_id":"s1","num_turns":1}`,
161+
ReturnCode: 0,
162+
}, nil
163+
}
164+
165+
raw, err := p.Execute(context.Background(), "hello", Options{Model: "sonnet#high"})
166+
require.NoError(t, err)
167+
require.False(t, raw.IsError, "unexpected error: %s", raw.ErrorMessage)
168+
169+
require.Contains(t, gotCmd, "--model")
170+
for i, arg := range gotCmd {
171+
if arg == "--model" {
172+
require.Greater(t, len(gotCmd), i+1)
173+
assert.Equal(t, "sonnet", gotCmd[i+1],
174+
"--model must carry the base model, not the #variant-suffixed string")
175+
}
176+
}
177+
assert.NotContains(t, gotCmd, "sonnet#high")
178+
assert.NotContains(t, gotCmd, "--variant")
179+
}

sdk/go/harness/codex.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,15 @@ func (p *CodexProvider) Execute(ctx context.Context, prompt string, options Opti
6565

6666
// Pass the model through with -m. SWE-AF resolves gpt-5.5 vs gpt-5.3-codex by
6767
// auth mode and relies on the value reaching the CLI; the old provider
68-
// ignored options.Model entirely.
69-
if options.Model != "" {
70-
cmd = append(cmd, "-m", options.Model)
68+
// ignored options.Model entirely. Reasoning effort has no dedicated flag —
69+
// it's the model_reasoning_effort config key, fed by a "#variant" suffix
70+
// on the model (or an explicit Options.Variant), e.g. "gpt-5.3-codex#high".
71+
modelValue, variantValue := options.resolveModelAndVariant()
72+
if modelValue != "" {
73+
cmd = append(cmd, "-m", modelValue)
74+
}
75+
if variantValue != "" {
76+
cmd = append(cmd, "-c", "model_reasoning_effort="+variantValue)
7177
}
7278

7379
// permission_mode → sandbox policy (port of codex_harness_patch.py:165-170).

sdk/go/harness/codex_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,3 +306,61 @@ func toStringSlice(v any) []string {
306306
return nil
307307
}
308308
}
309+
310+
// TestCodexProvider_ModelVariantReasoningEffort pins the model#variant parity
311+
// matrix from the Python provider tests (test_harness_provider_codex.py):
312+
// -m always carries the BASE model, a "#variant" suffix (or an explicit
313+
// Options.Variant, which wins) becomes -c model_reasoning_effort=<v>, and a
314+
// bare model adds no -c at all.
315+
func TestCodexProvider_ModelVariantReasoningEffort(t *testing.T) {
316+
run := func(t *testing.T, options Options) []string {
317+
t.Helper()
318+
var gotCmd []string
319+
p := NewCodexProvider("codex")
320+
p.runCLI = func(_ context.Context, cmd []string, _ map[string]string, _ string, _ int, _ []byte) (*CLIResult, error) {
321+
gotCmd = append([]string(nil), cmd...)
322+
return &CLIResult{Stdout: `{"type":"turn.completed","text":"ok"}`, ReturnCode: 0}, nil
323+
}
324+
raw, err := p.Execute(context.Background(), "hello", options)
325+
require.NoError(t, err)
326+
require.False(t, raw.IsError, "unexpected error: %s", raw.ErrorMessage)
327+
return gotCmd
328+
}
329+
330+
index := func(cmd []string, flag string) int {
331+
for i, arg := range cmd {
332+
if arg == flag {
333+
return i
334+
}
335+
}
336+
return -1
337+
}
338+
339+
t.Run("suffix maps to model_reasoning_effort", func(t *testing.T) {
340+
cmd := run(t, Options{Model: "gpt-5.3-codex#high"})
341+
mIdx := index(cmd, "-m")
342+
require.GreaterOrEqual(t, mIdx, 0)
343+
assert.Equal(t, "gpt-5.3-codex", cmd[mIdx+1], "-m must carry the base model, never the suffixed string")
344+
cIdx := index(cmd, "-c")
345+
require.GreaterOrEqual(t, cIdx, 0)
346+
assert.Equal(t, "model_reasoning_effort=high", cmd[cIdx+1])
347+
})
348+
349+
t.Run("explicit variant wins over suffix", func(t *testing.T) {
350+
cmd := run(t, Options{Model: "gpt-5.5#low", Variant: "max"})
351+
mIdx := index(cmd, "-m")
352+
require.GreaterOrEqual(t, mIdx, 0)
353+
assert.Equal(t, "gpt-5.5", cmd[mIdx+1])
354+
cIdx := index(cmd, "-c")
355+
require.GreaterOrEqual(t, cIdx, 0)
356+
assert.Equal(t, "model_reasoning_effort=max", cmd[cIdx+1])
357+
})
358+
359+
t.Run("bare model has no effort config", func(t *testing.T) {
360+
cmd := run(t, Options{Model: "gpt-5.5"})
361+
mIdx := index(cmd, "-m")
362+
require.GreaterOrEqual(t, mIdx, 0)
363+
assert.Equal(t, "gpt-5.5", cmd[mIdx+1])
364+
assert.NotContains(t, cmd, "-c")
365+
})
366+
}

sdk/go/harness/gemini.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,11 @@ func (p *GeminiProvider) Execute(ctx context.Context, prompt string, options Opt
3535
cmd = append(cmd, "--sandbox")
3636
}
3737

38-
if options.Model != "" {
39-
cmd = append(cmd, "-m", options.Model)
38+
// gemini has no reasoning-effort flag; strip any "#variant" suffix so
39+
// the CLI still receives a valid model id, and drop the variant.
40+
modelValue, _ := options.resolveModelAndVariant()
41+
if modelValue != "" {
42+
cmd = append(cmd, "-m", modelValue)
4043
}
4144

4245
// Prompt passed via -p flag.

sdk/go/harness/model.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package harness
2+
3+
import "strings"
4+
5+
// ModelVariantSep separates the base model id from a reasoning-effort variant
6+
// in the "provider/model#variant" model-string syntax. "#" is safe because
7+
// provider model ids never contain it (":" is taken by OpenRouter suffixes
8+
// like ":free", "@" by Vertex-style ids).
9+
const ModelVariantSep = "#"
10+
11+
// SplitModelVariant splits a "provider/model#variant" string into its base
12+
// model and variant parts.
13+
//
14+
// The "#variant" suffix carries a provider-specific reasoning-effort variant
15+
// (e.g. "high", "minimal") through config surfaces that only hold a model
16+
// string. The split happens on the FIRST separator and both parts are
17+
// whitespace-trimmed. A bare model string passes through as (model, "");
18+
// an empty or blank input yields ("", ""). Mirrors the Python SDK's
19+
// split_model_variant (harness/_cli.py).
20+
func SplitModelVariant(model string) (base, variant string) {
21+
if strings.TrimSpace(model) == "" {
22+
return "", ""
23+
}
24+
base, variant, _ = strings.Cut(model, ModelVariantSep)
25+
return strings.TrimSpace(base), strings.TrimSpace(variant)
26+
}
27+
28+
// resolveModelAndVariant resolves the (model, variant) pair for a harness
29+
// invocation. An explicit Options.Variant wins over a "#variant" suffix on
30+
// Options.Model; the returned model never carries the suffix. Mirrors the
31+
// Python SDK's resolve_model_and_variant (harness/_cli.py).
32+
func (o Options) resolveModelAndVariant() (model, variant string) {
33+
model, variant = SplitModelVariant(o.Model)
34+
if explicit := strings.TrimSpace(o.Variant); explicit != "" {
35+
variant = explicit
36+
}
37+
return model, variant
38+
}

0 commit comments

Comments
 (0)