Skip to content

feat(sdk/llm): layered per-model config + caps via optional store interfaces#25

Merged
lIang70 merged 2 commits into
mainfrom
feat/sdk-per-model-config
Apr 20, 2026
Merged

feat(sdk/llm): layered per-model config + caps via optional store interfaces#25
lIang70 merged 2 commits into
mainfrom
feat/sdk-per-model-config

Conversation

@lIang70

@lIang70 lIang70 commented Apr 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #24.

DefaultResolver previously consumed a single ProviderConfigStore and silently dropped any per-model overrides users wrote — e.g. extra.caps stored on a model:openai/gpt-4o row never reached CapsMiddleware. This PR adds two opt-in store extensions, one strong-typed override field, and rewrites createLLM so per-model config rounds-trip end-to-end.

New surface

Symbol Purpose
ModelConfigStore.GetModelConfig(ctx, provider, model) Optional. Per-model {Caps, Extra} overrides; errdefs.NotFound is silently ignored, any other error fails Resolve.
DefaultModelStore.GetDefaultModel(ctx) Optional. Typed default-model pointer, replacing the magic "global_default" provider row.
ProviderConfig.Caps Strong-typed provider-wide caps that no longer require stuffing into the untyped Config map.
ModelConfig{Provider, Model, Caps, Extra} New override struct returned by ModelConfigStore.
DefaultModelRef{Provider, Model} New typed return for DefaultModelStore.
WithExtraCaps(caps) Renamed from WithModelCaps (made the "additive" semantics explicit).

Caps merge order

createLLM now OR-merges five layers — any layer disabling a capability wins:

  1. ProviderRegistry.LookupModelCaps(provider, model) — static catalog
  2. ProviderConfig.Caps — provider-wide user config
  3. capsFromConfig(merged) — legacy Config["caps"] (deprecated, still honored)
  4. ModelConfig.Caps — per-model user config
  5. resolver.extraCapsWithExtraCaps/WithModelCaps

ModelConfig.Extra is shallow-merged (per-model keys overwrite provider keys) before being handed to the provider factory.

Default-model lookup order

Resolve("") now tries:

  1. DefaultModelStore.GetDefaultModel (preferred)
  2. Legacy "global_default" provider row (deprecated)
  3. WithFallbackModel

Backward compatibility

  • Provider-only stores (e.g. animus) keep working unchanged — Resolve only type-asserts the optional interfaces.
  • The legacy "global_default" lookup still runs when DefaultModelStore is absent or returns NotFound.
  • capsFromConfig still parses the legacy Config["caps"] sub-object.
  • WithModelCaps is preserved as a thin alias forwarding to WithExtraCaps.

Deprecations (all tagged for removal in v0.2.0)

This PR also audits the rest of sdk/ and ensures every Deprecated: tag explicitly states removal in v0.2.0:

  • llm.GlobalDefaultProvider — implement DefaultModelStore instead
  • llm.WithModelCaps — use WithExtraCaps
  • llm.capsFromConfig / Config["caps"] — use ProviderConfig.Caps / ModelConfig.Caps
  • kanban.TaskBoard, kanban.NewTaskBoard — use Board / NewBoard
  • (*kanban.Scheduler).SetKanban — use WithScheduler
  • compiler.ValidateGraphDef — call def.Validate() directly
  • telemetry.WithLogExporter / WithLogConsole / WithLogMinSeverity (already tagged before this PR)

sdkx/ has no Deprecated: symbols and is unchanged.

Test API hygiene (commit 2)

Resolver tests no longer construct &defaultResolver{...} directly or reference deprecated symbols. They go through DefaultResolver(...) + WithFallbackModel / WithExtraCaps, plus a single test-only newResolverWithRegistry helper that swaps the registry on a real DefaultResolver — the only field tests need to override that the public API doesn't expose.

Tests covering the legacy magic key are renamed with a LegacyMagicKey_ prefix and a comment pointing at the v0.2.0 removal, so they're easy to delete together with the constant.

Test plan

  • cd sdk && go test ./... — all green
  • cd sdkx && go test ./... — all green (no consumer changes needed; SDKX doesn't touch llm.ProviderConfig / DefaultResolver)
  • cd plugin && go test ./... — all green
  • go build ./... && go vet ./... per module — clean
  • New tests in sdk/llm/resolver_layered_test.go cover:
    • per-model Extra shallow-merge over provider config
    • ModelConfigStore NotFound vs other-error semantics
    • DefaultModelStore preferred / falls back to legacy magic key / falls back to WithFallbackModel
    • 4-layer caps OR merge (catalog × provider × model × resolver-extra)
    • legacy Config["caps"] still respected (deprecation regression)
    • provider-only stores keep working (animus regression)
  • Auto-tag workflow will cut sdk/v0.1.11; cascade PR to bump sdkx/go.mod will follow automatically.

Notes for reviewers

  • unwrapCaps exists to avoid double-wrapping CapsMiddleware: NewFromConfig already wraps the instance once with (catalog ⊕ legacy Config["caps"]), and the resolver re-wraps with the full 5-layer composition. Without unwrapCaps the caller would see two stacked wrappers — functionally correct but harder to reason about and slightly less efficient.
  • mergeCaps was changed from (a, b) to variadic (...) to make the 5-layer composition in createLLM read top-to-bottom without nesting.
  • migrateVarsMessages in sdk/workflow/board.go is left untouched: it's unexported (Go tooling never treats it as a deprecation) and its "v2 migration window" refers to the board-snapshot data format, not the SDK module version.

Made with Cursor

lIang70 added 2 commits April 20, 2026 18:09
…nterfaces

Closes #24.

DefaultResolver previously consumed a single ProviderConfigStore and
silently dropped any per-model overrides users wrote (e.g. extra.caps
on a "model:openai/gpt-4o" row never reached CapsMiddleware). This
change introduces two opt-in store extensions plus strong-typed
override structs so per-model config rounds-trip end-to-end without
losing type safety:

  - ModelConfigStore.GetModelConfig — per-model {Caps, Extra} overrides
    that shallow-merge over the provider config and OR-merge with the
    catalog/provider/extra caps layers.
  - DefaultModelStore.GetDefaultModel — typed default-model pointer,
    replacing the magic "__global_default__" provider row.
  - ProviderConfig.Caps — explicit provider-wide caps field that no
    longer requires stuffing into the untyped Config map.

Caps now compose as an OR across (registry catalog, ProviderConfig.Caps,
legacy Config["caps"], ModelConfig.Caps, resolver-wide WithExtraCaps);
any layer disabling a capability wins.

Backward compatibility:

  - Stores that only implement ProviderConfigStore (e.g. animus) keep
    working unchanged — Resolve() never type-asserts mandatorily.
  - The legacy GlobalDefaultProvider lookup still runs when
    DefaultModelStore is absent or returns NotFound.
  - capsFromConfig still reads the legacy Config["caps"] sub-object.
  - WithModelCaps is renamed to WithExtraCaps; the old name is kept as
    a thin forwarding alias.

Deprecated, scheduled for removal in v0.2.0:

  - llm.GlobalDefaultProvider constant
  - llm.WithModelCaps option (use WithExtraCaps)
  - capsFromConfig / Config["caps"] (use ProviderConfig.Caps or
    ModelConfig.Caps)

Tests cover:

  - per-model Extra shallow-merge over provider config
  - ModelConfigStore NotFound vs other-error semantics
  - DefaultModelStore preferred / falls back to legacy / falls back to
    WithFallbackModel
  - 4-layer caps OR merge (catalog × provider × model × resolver-extra)
  - legacy Config["caps"] still respected
  - provider-only stores keep working (animus regression)
  - WithModelCaps deprecation alias still gates caps

Made-with: Cursor
…through public API

Two cleanups bundled together because they're both follow-ups to the
per-model-config refactor (ba229c4):

1. Deprecation audit. Every Deprecated: tag in sdk/ now explicitly
   states removal in v0.2.0, matching the policy already documented on
   the resolver/telemetry options:

     - sdk/kanban/board.go: TaskBoard, NewTaskBoard
     - sdk/kanban/scheduler.go: (*Scheduler).SetKanban
     - sdk/graph/compiler/compiler.go: ValidateGraphDef
     - sdk/llm/factory.go: capsFromConfig wording cleanup

   sdkx/ has no Deprecated symbols and was unchanged.

   sdk/workflow/board.go's migrateVarsMessages is intentionally left
   alone: it is unexported (Go tooling never treats it as a deprecation)
   and its "v2 migration window" refers to the board-snapshot data
   format, not the SDK module version.

2. Test API hygiene. Resolver tests no longer construct
   &defaultResolver{...} directly or reference the deprecated
   GlobalDefaultProvider constant / WithModelCaps option. They go
   through DefaultResolver(...) + WithFallbackModel / WithExtraCaps,
   plus a single test-only helper newResolverWithRegistry that swaps
   the registry on the resolver returned by DefaultResolver — the only
   field tests need to override that the public API doesn't expose.

   Tests that exercise the legacy "__global_default__" magic key are
   renamed with a LegacyMagicKey_ prefix and a comment pointing at the
   v0.2.0 removal so they're easy to delete together with the constant.

Made-with: Cursor
@lIang70
lIang70 merged commit c8d5fd5 into main Apr 20, 2026
8 checks passed
@lIang70
lIang70 deleted the feat/sdk-per-model-config branch April 20, 2026 10:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SDK: per-model config overrides are silently dropped by DefaultResolver

1 participant