mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-08-31 10:35:56 +00:00
* feat(providers): add AWS Bedrock as a built-in provider
Bedrock serves the same Messages API as api.anthropic.com, so this reuses
AnthropicClient wholesale and lets the official SDK's bedrock middleware
handle what differs: SigV4 signing, moving the model from the body into the
URL path, injecting anthropic_version, and deriving the host from the region.
No new protocol implementation, no AWS request plumbing.
Configuration is an empty provider entry — there is no api_key to set:
{
"provider": "bedrock",
"model": "us.anthropic.claude-sonnet-4-6",
"providers": { "bedrock": { "aws_profile": "...", "aws_region": "..." } }
}
aws_profile and aws_region are optional; without them the standard AWS chain
decides, as with any other AWS tool. Setting them makes a run reproducible
without exporting AWS_PROFILE first. Model accepts a foundation model ID, an
inference profile ID, or an application inference profile ARN when usage has
to be attributed for cost allocation.
Four things this needed beyond registering a provider, each found by running
it rather than reading it:
- The resolver required a non-empty api_key, and separately required both
URL and Token to consider an endpoint complete. Bedrock has none of the
three, so a correct config fell through every strategy and reported "no
valid LLM endpoint configured" — the error for having configured nothing.
Both gates now recognise ambient authentication, via an AmbientAuth flag
on Provider and ResolvedEndpoint. Providers that do use api_key are
unaffected, which TestNonAmbientProviderStillRequiresAPIKey pins.
- bedrock.WithConfig prefers bearer auth over SigV4 whenever
cfg.BearerAuthTokenProvider is non-nil, and LoadDefaultConfig populates
that provider from the SSO token cache. An SSO-authenticated caller —
most enterprise setups — therefore sent its OIDC access token and got
403 "Invalid API Key format: Must start with pre-defined prefix". The
provider is cleared unless AWS_BEARER_TOKEN_BEDROCK was set deliberately,
which restores SigV4 while leaving an explicit bearer token working.
- The SDK would also attach an API-key header of its own, which Bedrock
rejects even when empty. Authorization and X-Api-Key are removed before
the signing middleware runs.
- bedrock.WithLoadDefaultConfig panics when AWS config cannot be loaded.
A CLI should not answer an expired session with a stack trace, so the
config is loaded directly and the failure deferred to the first request
as a sentence naming the likely fix.
The preset's Models list is taken verbatim from `aws bedrock list-inference-profiles`
on a live account rather than inferred: suffix conventions vary per family, so
us.anthropic.claude-sonnet-5 is correct while us.anthropic.claude-sonnet-5-v1:0 is
rejected with 400 "The provided model identifier is invalid." The global.* cross-region
variants are listed alongside us.* since either is a valid routing target. That list
only gates --model overrides; an application inference profile ARN still works via the
model field.
Two existing tests needed updating: the provider-order list gains "bedrock",
and TestProviders_AllProtocolsCanonical now delegates to ValidateProtocol
instead of re-listing the canonical names, so the next protocol added cannot
silently leave it behind.
Verified end-to-end against a live Bedrock account: reviews complete and
return findings using SigV4 credentials from an SSO profile, with no AWS
variables in the environment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(config): configure and diagnose an ambient-auth provider from the CLI
Registering the provider was not enough to make it usable: every config-related
path still assumed an api_key, and Bedrock's own error wording sends users after
the wrong problem.
- ProviderEntry gains aws_profile and aws_region. They were readable by the
resolver but absent from the struct the CLI marshals, and config is
unmarshalled into it and written back on every config command — so a
hand-written aws_region was silently deleted the first time the user ran
`ocr config model`, with no error and nothing to suggest why the next review
reached a different region.
- `ocr config set providers.<name>.aws_region|aws_profile` now works, for both
the providers and custom_providers paths. Values are trimmed; whitespace
inside one is rejected. Region names are deliberately not validated against
a fixed list — AWS adds regions faster than an embedded list stays correct,
and a wrong region already fails at request time. Setting either field on a
provider that authenticates by api_key is an error rather than dead config
that reads as applied.
- The provider wizard treats the model step as final for an ambient provider
instead of demanding a key. An API-key prompt that has to be left blank reads
as a step the user failed to complete, and applyOfficialProviderConfig
rejected the empty value anyway, so bedrock was unreachable through
`ocr config provider` entirely. The gate is now a named check keyed off
AmbientAuth, so key-based providers keep the requirement.
- `ocr llm test` prints the resolved region and profile in place of the URL,
which is empty for bedrock because the region decides the host. A request
that reached the wrong region otherwise fails as though the model ID were
malformed.
- Bedrock rejections are translated into the action that fixes them, since two
of them are actively misleading as the service words them: "Invalid API Key
format" names a credential no bedrock user can configure (it means a bearer
token reached the request), and a model merely absent from the region comes
back as "The provided model identifier is invalid." Expired credentials point
at `aws sso login` with the profile filled in; AccessDenied is named as an
IAM gap on bedrock:InvokeModel rather than a bad credential; a rejected model
points at `aws bedrock list-inference-profiles` and the -v1:0 suffix trap.
Every other protocol shares this client type, so the translation is gated on
the bedrock flag and returns other errors untouched.
The unknown-config-key message is pinned byte-for-byte by an existing test; it is
updated for the two new provider fields and for anthropic-bedrock as a protocol
value.
Verified against a live Bedrock account: `ocr llm test` reports region and
profile and completes over SigV4; a -v1:0 model ID and an unresolvable profile
each produce their intended message rather than a bare 400 or 403.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(bedrock): correct bearer-token precedence, error triage, and model gating
Four defects found reviewing the two commits before this one. Each was verified
by execution or against SDK source, not inferred.
- AWS_BEARER_TOKEN_BEDROCK was unreachable for exactly the users it was meant
to serve. The provider was cleared only when the variable was unset, on the
strength of WithConfig's doc comment ("if the AWS_BEARER_TOKEN_BEDROCK
environment variable is set, it is used"). The code disagrees with that
comment: bedrock.go consults the variable only `if
cfg.BearerAuthTokenProvider == nil`. So an SSO profile plus a deliberately
configured Bedrock API key sent the SSO OIDC token instead of the key — the
same silent substitution this patch exists to prevent, and explainError then
blamed a token that never left the machine. Cleared unconditionally now,
which is what gives the variable the precedence it documents.
- A model that the account has not enabled was reported as an IAM problem.
Bedrock answers both authorization failures with AccessDeniedException, and
the fixes have nothing in common: "You don't have access to the model with
the specified model ID" needs model access granted in the console, per
account and per region, which no IAM policy provides. The specific wording
is now matched ahead of the generic code, and the clause for it is no longer
stranded in an unreachable branch.
- A bare ValidationException match claimed every request-shape rejection was a
model-ID problem: "Input is too long for requested model" sent the user off
to list inference profiles. Only the model-identifier wording is matched now;
everything else keeps the service's own message, which is the whole point of
the function. The credential-expiry arm likewise no longer matches a bare
"expired", which caught `x509: certificate has expired`.
- --model rejected any Bedrock identifier absent from the preset's Models list,
contradicting both the preset's own comment and this PR's description. A
preset list cannot be an allowlist here: identifiers are scoped to an account
and a region, and an application inference profile ARN — the value to use
when spend has to be attributed — can never appear in a list compiled
upstream. The list stays a picker for `ocr config model`; it no longer gates
an override for an ambient-auth provider. Key-based providers keep the
check, so a typo against a hosted API is still caught locally.
Also: dropped a cfg.URL normalization block that could not have any effect,
since WithConfig is appended last and installs its own base URL — the comment
claimed a purpose the code did not have. Pinned AWS_CONFIG_FILE in the test that
constructs a client, which was reading the developer's real ~/.aws/config. Fixed
the column alignment of the region line in `ocr llm test`.
Verified: `ocr llm test` still completes over SigV4 against a live account; an
identifier the preset does not list now reaches Bedrock and returns Bedrock's own
verdict rather than a local rejection.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(bedrock): honour an entry-level protocol override for ambient auth
OCR's own review of this PR found that ambient auth was read off the preset while
the protocol could be overridden per entry, which left two ways to configure
something that reads as applied and cannot work.
`providers.bedrock.protocol = openai` resolved with no api_key and no URL: the
key requirement was skipped because the preset declares AmbientAuth, but the
endpoint then spoke a protocol with no SigV4 signing and carried nothing to
authenticate with. Ambient auth is now derived from the protocol actually in
force, after the override is applied, so such an entry needs a token again — and
conversely an entry that selects the bedrock protocol explicitly signs its
requests whatever preset it sits under. The same value gates the --model
allowlist, which had the same preset-only assumption.
`ocr config set providers.bedrock.aws_region` accepted AWS settings on that same
overridden entry. The check now lets the entry's protocol decide whenever it sets
one, falling back to the preset's flag only when the entry is silent.
Also corrects two stale doc comments the review flagged: ValidateProtocol accepts
four protocol names, not three, and the package comment now lists
anthropic-bedrock among the supported protocols.
The third finding in that review — a bare ValidationException match in
explainError — was already fixed in the preceding commit; the bot reviewed the
commit before it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* build(deps): bump aws-sdk-go-v2 to clear GO-2026-5764
govulncheck fails the CI test job because the pinned AWS SDK tree pulls
in github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3, which
is affected by GO-2026-5764 (fixed in v1.7.8). Upgrading the direct
dependency aws-sdk-go-v2/config to current resolves eventstream to
v1.7.16, past the fixed version.
The diff is scoped to the AWS module tree (plus smithy-go, its runtime
companion); no other dependencies move. The Bedrock provider's behavior
is unchanged: the newer config module still populates
BearerAuthTokenProvider from the SSO token cache, so the unconditional
clearing in NewAnthropicBedrockClient remains necessary and correct,
and it still does not consult AWS_BEARER_TOKEN_BEDROCK itself, so the
anthropic-sdk-go re-read of that variable keeps its documented
precedence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(bedrock): correct error triage, ambient credential handling, and the protocol's reach
Addresses the six pre-merge items from review.
- "not authorized to invoke this API operation" sat in the model-access
branch. It is IAM's own wording, so it pointed at a console toggle when
the fix is a policy change; it now falls to the AccessDenied branch,
whose message names bedrock:InvokeModel. A test case carries the phrase
without "AccessDenied" in the text, so the phrase itself is pinned to
the authorization branch rather than the exception name.
- TestExplainErrorClassifiesBedrockFailures read the ambient
AWS_BEARER_TOKEN_BEDROCK and asserted the message produced when it is
unset, so the suite failed on any machine that exports one. It pins the
variable empty. Reproduced before the fix with
AWS_BEARER_TOKEN_BEDROCK=sk-x go test -run TestExplainError.
- api_key_cmd ran for an ambient-auth provider. The output is discarded by
a signed request, and the command is typically a secret-manager read, so
a bedrock user got a real 1Password / Touch ID prompt for nothing — while
the comment above the call claimed it could not happen. Gated on
!ambientAuth; the new test proves non-execution with a sentinel file, and
fails without the gate.
- llm.protocol and OCR_LLM_PROTOCOL validated anthropic-bedrock and then
ignored it. Both strategies describe one URL and one token, have nowhere
to carry a region or a profile, and bedrock uses neither value they do
carry, so the request would have been signed and re-hosted with the rest
of the block silently dropped. Both now reject it, as does
`ocr config set llm.protocol`, at the point the value is typed.
- The custom-provider contract was split in two: the TUI never offered
bedrock, while the resolver demanded a url the bedrock client never
reads. Settled toward supporting it — a provider entry is the one place
with somewhere to put aws_region and aws_profile, which is what lets a
second region or profile have its own entry. url is now required for
every protocol except bedrock, the Custom form offers bedrock and ends at
the protocol step (there is no url, api key or auth header to collect),
and switching an existing entry to it clears the three fields the
previous protocol needed rather than leaving them as dead config. The
Manual form keeps the three-protocol list, since it writes llm.url and
llm.auth_token.
- Documented the bedrock.WithConfig append site: options wrap in order, so
appending last leaves signing innermost — closest to the wire, and re-run
on each retry rather than replaying a stale signature.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(pages): document the bedrock provider in all four locales
The provider shipped with no documentation: no row in the built-in table, and
no mention of aws_region or aws_profile, so the only way to learn either field
existed was to read the resolver.
Adds to en, ja, ru and zh:
- a `bedrock` row in the built-in provider table, with the host derived from
aws_region and no API key env var, since neither applies;
- an "AWS Bedrock" section covering the two AWS fields and what each falls
back to, why model IDs are not validated against the shipped list (they are
scoped to an account and a region, and an application inference profile ARN
can never appear in a list compiled upstream), the `-v1:0` suffix trap, and
the region/profile lines `ocr llm test` prints in place of a URL;
- anthropic-bedrock in the custom-provider protocol list, with the example
that needs no url and takes the same AWS fields — the supported way to run
a second region or profile;
- a note that llm.protocol and OCR_LLM_PROTOCOL reject it, and why.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(bedrock): clear stale AWS settings on protocol switch, bound config load
applyProviderField's "protocol" case only validated and wrote the new
protocol, leaving aws_region/aws_profile behind when an entry switched
away from anthropic-bedrock. Setting the AWS fields first (while the
entry was still ambient) and only then switching protocol produced
exactly the dead config the aws_region/aws_profile write path already
guards against on the other ordering. Clear both fields when the new
protocol isn't anthropic-bedrock, with a stderr warning, mirroring the
TUI's cpAmbientProtocol() cleanup.
Also bound NewAnthropicBedrockClient's awsconfig.LoadDefaultConfig with
a context timeout instead of context.Background(), as defense in
depth against network calls region auto-detection can still make.
Credential resolution itself (SSO refresh, AssumeRole,
credential_process) is lazy and already bounded by cfg.Timeout at
request time, so this does not fix an observed hang, but removes an
unbounded context where the AWS SDK's own defaults are the only guard.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: kite <lizhengfeng.lzf@alibaba-inc.com>
1602 lines
48 KiB
Go
1602 lines
48 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright 2026 alibaba/open-code-review Contributors
|
|
|
|
package main
|
|
|
|
import (
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/alibaba/open-code-review/internal/llm"
|
|
)
|
|
|
|
func TestSetConfigValueAuthHeaderNormalizesKnownValues(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "llm.auth_header", " bearer "); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
|
|
if cfg.Llm.AuthHeader != "authorization" {
|
|
t.Errorf("AuthHeader = %q, want %q", cfg.Llm.AuthHeader, "authorization")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueAuthHeaderRejectsCustomHeader(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "llm.auth_header", " X-Custom-Auth "); err == nil {
|
|
t.Fatal("expected error for unsupported auth_header, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProvider(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "provider", "anthropic"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Provider != "anthropic" {
|
|
t.Errorf("Provider = %q, want %q", cfg.Provider, "anthropic")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderURLTrimsAndValidates(t *testing.T) {
|
|
t.Run("trims a valid URL before storing", func(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "providers.litellm.url", " https://gateway.internal:8000/v1 "); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if got := cfg.Providers["litellm"].URL; got != "https://gateway.internal:8000/v1" {
|
|
t.Errorf("URL = %q, want trimmed URL", got)
|
|
}
|
|
})
|
|
|
|
for _, value := range []string{"api.example.com/v1", "ftp://gateway.internal/v1"} {
|
|
t.Run("rejects "+value, func(t *testing.T) {
|
|
if err := setConfigValue(&Config{}, "providers.litellm.url", value); err == nil {
|
|
t.Fatalf("setConfigValue accepted invalid URL %q", value)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueModel(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "model", "claude-opus-4-6"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Model != "claude-opus-4-6" {
|
|
t.Errorf("Model = %q, want %q", cfg.Model, "claude-opus-4-6")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueMaxTokens(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "max_tokens", "200000"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.MaxTokens != 200000 {
|
|
t.Errorf("MaxTokens = %d, want 200000", cfg.MaxTokens)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueMaxTokensRejectsInvalidValues(t *testing.T) {
|
|
for _, value := range []string{"0", "-1", "not-a-number"} {
|
|
t.Run(value, func(t *testing.T) {
|
|
if err := setConfigValue(&Config{}, "max_tokens", value); err == nil {
|
|
t.Fatalf("expected max_tokens=%q to be rejected", value)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMaxTokensConfigRoundTrip(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "config.json")
|
|
cfg := &Config{MaxTokens: 200000}
|
|
|
|
if err := saveConfig(path, cfg); err != nil {
|
|
t.Fatalf("saveConfig: %v", err)
|
|
}
|
|
loaded, err := LoadAppConfig(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadAppConfig: %v", err)
|
|
}
|
|
if loaded.MaxTokens != 200000 {
|
|
t.Errorf("MaxTokens = %d, want 200000", loaded.MaxTokens)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueModelWithProvider(t *testing.T) {
|
|
cfg := &Config{
|
|
Provider: "anthropic",
|
|
Providers: map[string]ProviderEntry{
|
|
"anthropic": {APIKey: "sk-test"},
|
|
},
|
|
}
|
|
|
|
if err := setConfigValue(cfg, "model", "claude-opus-4-6"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Providers["anthropic"].Model != "claude-opus-4-6" {
|
|
t.Errorf("entry Model = %q, want %q", cfg.Providers["anthropic"].Model, "claude-opus-4-6")
|
|
}
|
|
if cfg.Model != "" {
|
|
t.Errorf("top-level Model = %q, want empty (should write to provider entry)", cfg.Model)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderEntry(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "providers.anthropic.api_key", "sk-ant-test"); err != nil {
|
|
t.Fatalf("setConfigValue api_key: %v", err)
|
|
}
|
|
if cfg.Providers["anthropic"].APIKey != "sk-ant-test" {
|
|
t.Errorf("api_key = %q, want %q", cfg.Providers["anthropic"].APIKey, "sk-ant-test")
|
|
}
|
|
|
|
if err := setConfigValue(cfg, "providers.anthropic.model", "claude-opus-4-6"); err != nil {
|
|
t.Fatalf("setConfigValue model: %v", err)
|
|
}
|
|
if cfg.Providers["anthropic"].Model != "claude-opus-4-6" {
|
|
t.Errorf("model = %q, want %q", cfg.Providers["anthropic"].Model, "claude-opus-4-6")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueKeyCmdFields(t *testing.T) {
|
|
// A typo in any of these case labels would silently degrade to "unknown
|
|
// provider field" / "unknown config key", so assert the field each key writes.
|
|
const value = "op read op://dev/anthropic/api-key"
|
|
tests := []struct {
|
|
name string
|
|
key string
|
|
got func(cfg *Config) string
|
|
}{
|
|
{"preset provider api_key_cmd", "providers.anthropic.api_key_cmd", func(cfg *Config) string { return cfg.Providers["anthropic"].APIKeyCmd }},
|
|
{"custom provider api_key_cmd", "custom_providers.my-gateway.api_key_cmd", func(cfg *Config) string { return cfg.CustomProviders["my-gateway"].APIKeyCmd }},
|
|
{"llm auth_token_cmd", "llm.auth_token_cmd", func(cfg *Config) string { return cfg.Llm.AuthTokenCmd }},
|
|
{"llm AuthTokenCmd alias", "llm.AuthTokenCmd", func(cfg *Config) string { return cfg.Llm.AuthTokenCmd }},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, tt.key, value); err != nil {
|
|
t.Fatalf("setConfigValue %s: %v", tt.key, err)
|
|
}
|
|
if got := tt.got(cfg); got != value {
|
|
t.Errorf("%s = %q, want %q", tt.key, got, value)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestShouldMaskConfigValue(t *testing.T) {
|
|
// api_key/auth_token values are secrets; the *_cmd variants are command
|
|
// lines, so they print unmasked.
|
|
tests := []struct {
|
|
key string
|
|
want bool
|
|
}{
|
|
{"llm.auth_token", true},
|
|
{"llm.auth_token_cmd", false},
|
|
{"providers.x.api_key", true},
|
|
{"providers.x.api_key_cmd", false},
|
|
{"providers.x.APIKeyCmd", false},
|
|
{"llm.AuthToken", true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.key, func(t *testing.T) {
|
|
if got := shouldMaskConfigValue(tt.key); got != tt.want {
|
|
t.Errorf("shouldMaskConfigValue(%q) = %v, want %v", tt.key, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderEntryNonPresetWritesCustomProvider(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "providers.my-gateway.url", "https://gateway.internal.com/v1"); err != nil {
|
|
t.Fatalf("setConfigValue url: %v", err)
|
|
}
|
|
|
|
if cfg.Providers != nil {
|
|
if _, ok := cfg.Providers["my-gateway"]; ok {
|
|
t.Fatal("non-preset providers.<name> should be stored in CustomProviders, not Providers")
|
|
}
|
|
}
|
|
if cfg.CustomProviders["my-gateway"].URL != "https://gateway.internal.com/v1" {
|
|
t.Errorf("custom provider URL = %q", cfg.CustomProviders["my-gateway"].URL)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderEntryModelsJSON(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "custom_providers.my-gateway.models", `["llama-3-70b","llama-3-8b","llama-3-70b"]`); err != nil {
|
|
t.Fatalf("setConfigValue models: %v", err)
|
|
}
|
|
|
|
got := cfg.CustomProviders["my-gateway"].Models
|
|
want := []string{"llama-3-70b", "llama-3-8b"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("models length = %d, want %d: %#v", len(got), len(want), got)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Errorf("models[%d] = %q, want %q", i, got[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderEntryModelsCommaSeparated(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "custom_providers.my-gateway.models", " llama-3-70b, llama-3-8b ,, llama-3-70b "); err != nil {
|
|
t.Fatalf("setConfigValue models: %v", err)
|
|
}
|
|
|
|
got := cfg.CustomProviders["my-gateway"].Models
|
|
want := []string{"llama-3-70b", "llama-3-8b"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("models length = %d, want %d: %#v", len(got), len(want), got)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Errorf("models[%d] = %q, want %q", i, got[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderEntryModelsUnquotedBracketList(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "custom_providers.my-gateway.models", "[llama-3-70b,llama-3-8b]"); err != nil {
|
|
t.Fatalf("setConfigValue models: %v", err)
|
|
}
|
|
|
|
got := cfg.CustomProviders["my-gateway"].Models
|
|
want := []string{"llama-3-70b", "llama-3-8b"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("models length = %d, want %d: %#v", len(got), len(want), got)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Errorf("models[%d] = %q, want %q", i, got[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderEntryProtocol(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "custom_providers.custom.protocol", "openai"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.CustomProviders["custom"].Protocol != llm.ProtocolOpenAIChatCompletions {
|
|
t.Errorf("protocol = %q, want %q (openai alias normalized)", cfg.CustomProviders["custom"].Protocol, llm.ProtocolOpenAIChatCompletions)
|
|
}
|
|
|
|
if err := setConfigValue(cfg, "custom_providers.custom.protocol", "invalid"); err == nil {
|
|
t.Fatal("expected error for invalid protocol")
|
|
}
|
|
|
|
if err := setConfigValue(cfg, "custom_providers.custom.protocol", "anthropic-vertex"); err == nil {
|
|
t.Fatal("expected error for unsupported protocol anthropic-vertex")
|
|
}
|
|
|
|
if err := setConfigValue(cfg, "custom_providers.custom.protocol", "openai-responses"); err != nil {
|
|
t.Fatalf("setConfigValue openai-responses: %v", err)
|
|
}
|
|
if cfg.CustomProviders["custom"].Protocol != llm.ProtocolOpenAIResponses {
|
|
t.Errorf("protocol = %q, want %q", cfg.CustomProviders["custom"].Protocol, llm.ProtocolOpenAIResponses)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmProtocol(t *testing.T) {
|
|
// Each protocol mirrors use_anthropic so older binaries that predate
|
|
// llm.protocol still pick the right protocol family.
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
wantProtocol string
|
|
wantUseAnthro bool
|
|
}{
|
|
{"anthropic mirrors true", "anthropic", llm.ProtocolAnthropic, true},
|
|
{"openai alias mirrors false", "openai", llm.ProtocolOpenAIChatCompletions, false},
|
|
{"openai-responses mirrors false", "openai-responses", llm.ProtocolOpenAIResponses, false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "llm.protocol", tt.value); err != nil {
|
|
t.Fatalf("setConfigValue llm.protocol: %v", err)
|
|
}
|
|
if cfg.Llm.Protocol != tt.wantProtocol {
|
|
t.Errorf("cfg.Llm.Protocol = %q, want %q", cfg.Llm.Protocol, tt.wantProtocol)
|
|
}
|
|
if cfg.Llm.UseAnthropic == nil || *cfg.Llm.UseAnthropic != tt.wantUseAnthro {
|
|
got := "<nil>"
|
|
if cfg.Llm.UseAnthropic != nil {
|
|
got = strconv.FormatBool(*cfg.Llm.UseAnthropic)
|
|
}
|
|
t.Errorf("cfg.Llm.UseAnthropic = %s, want %v", got, tt.wantUseAnthro)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("overwrites stale use_anthropic when switching protocol", func(t *testing.T) {
|
|
stale := true
|
|
cfg := &Config{}
|
|
cfg.Llm.UseAnthropic = &stale
|
|
if err := setConfigValue(cfg, "llm.protocol", "openai-responses"); err != nil {
|
|
t.Fatalf("setConfigValue llm.protocol: %v", err)
|
|
}
|
|
if cfg.Llm.UseAnthropic == nil || *cfg.Llm.UseAnthropic {
|
|
t.Error("UseAnthropic should be false (overwriting stale true)")
|
|
}
|
|
})
|
|
|
|
t.Run("rejects invalid protocol", func(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "llm.protocol", "grpc"); err == nil {
|
|
t.Fatal("expected error for invalid llm.protocol")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSetConfigValueProviderEntryInvalidKey(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "providers.anthropic.unknown_field", "value"); err == nil {
|
|
t.Fatal("expected error for unknown provider field")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderEntryInvalidPath(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "providers.anthropic", "value"); err == nil {
|
|
t.Fatal("expected error for incomplete provider path")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderEntryExtraBody(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "providers.anthropic.extra_body", `{"thinking":{"type":"disabled"}}`); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Providers["anthropic"].ExtraBody == nil {
|
|
t.Fatal("extra_body should not be nil")
|
|
}
|
|
if _, ok := cfg.Providers["anthropic"].ExtraBody["thinking"]; !ok {
|
|
t.Error("extra_body missing 'thinking' key")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueModelWithCustomProvider(t *testing.T) {
|
|
cfg := &Config{
|
|
Provider: "my-gateway",
|
|
CustomProviders: map[string]ProviderEntry{
|
|
"my-gateway": {URL: "https://gw.example.com/v1", Protocol: "openai"},
|
|
},
|
|
}
|
|
|
|
if err := setConfigValue(cfg, "model", "llama-3-70b"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.CustomProviders["my-gateway"].Model != "llama-3-70b" {
|
|
t.Errorf("entry Model = %q, want %q", cfg.CustomProviders["my-gateway"].Model, "llama-3-70b")
|
|
}
|
|
if cfg.Model != "" {
|
|
t.Errorf("top-level Model = %q, want empty (should write to custom provider entry)", cfg.Model)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmExtraHeaders(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "llm.extra_headers", "X-Custom=val1, X-Org=val2"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
|
|
if cfg.Llm.ExtraHeaders == nil {
|
|
t.Fatal("ExtraHeaders should not be nil")
|
|
}
|
|
if v := cfg.Llm.ExtraHeaders["X-Custom"]; v != "val1" {
|
|
t.Errorf("ExtraHeaders[\"X-Custom\"] = %q, want %q", v, "val1")
|
|
}
|
|
if v := cfg.Llm.ExtraHeaders["X-Org"]; v != "val2" {
|
|
t.Errorf("ExtraHeaders[\"X-Org\"] = %q, want %q", v, "val2")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmExtraHeadersInvalid(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "llm.extra_headers", "no-equals-sign"); err == nil {
|
|
t.Fatal("expected error for invalid extra headers, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmExtraHeadersReservedRejected(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "llm.extra_headers", "Authorization=bad"); err == nil {
|
|
t.Fatal("expected error for reserved header, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderExtraHeaders(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "providers.anthropic.extra_headers", "X-Custom=val1, X-Org=val2"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
|
|
entry := cfg.Providers["anthropic"]
|
|
if entry.ExtraHeaders == nil {
|
|
t.Fatal("ExtraHeaders should not be nil")
|
|
}
|
|
if v := entry.ExtraHeaders["X-Custom"]; v != "val1" {
|
|
t.Errorf("ExtraHeaders[\"X-Custom\"] = %q, want %q", v, "val1")
|
|
}
|
|
if v := entry.ExtraHeaders["X-Org"]; v != "val2" {
|
|
t.Errorf("ExtraHeaders[\"X-Org\"] = %q, want %q", v, "val2")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderExtraHeadersInvalid(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "providers.anthropic.extra_headers", "=missing-key"); err == nil {
|
|
t.Fatal("expected error for invalid extra headers, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueCustomProviderExtraHeaders(t *testing.T) {
|
|
cfg := &Config{}
|
|
|
|
if err := setConfigValue(cfg, "custom_providers.my-gateway.extra_headers", "X-Gateway=secret"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
|
|
entry := cfg.CustomProviders["my-gateway"]
|
|
if entry.ExtraHeaders == nil {
|
|
t.Fatal("ExtraHeaders should not be nil")
|
|
}
|
|
if v := entry.ExtraHeaders["X-Gateway"]; v != "secret" {
|
|
t.Errorf("ExtraHeaders[\"X-Gateway\"] = %q, want %q", v, "secret")
|
|
}
|
|
}
|
|
|
|
// --- unset tests ---
|
|
|
|
func TestUnsetMaxTokens(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
cfg := &Config{Provider: "anthropic", MaxTokens: 200000}
|
|
if err := saveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("saveConfig: %v", err)
|
|
}
|
|
|
|
if err := unsetMaxTokens(configPath); err != nil {
|
|
t.Fatalf("unsetMaxTokens: %v", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatalf("read config: %v", err)
|
|
}
|
|
if strings.Contains(string(data), "max_tokens") {
|
|
t.Errorf("max_tokens should be omitted after unset: %s", data)
|
|
}
|
|
loaded, err := loadOrCreateConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("reload: %v", err)
|
|
}
|
|
if loaded.Provider != "anthropic" {
|
|
t.Errorf("Provider = %q, want anthropic", loaded.Provider)
|
|
}
|
|
}
|
|
|
|
func TestUnsetCustomProvider(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := dir + "/config.json"
|
|
|
|
cfg := &Config{
|
|
Provider: "anthropic",
|
|
CustomProviders: map[string]ProviderEntry{
|
|
"my-gateway": {URL: "https://gw.example.com/v1", Protocol: "openai", Model: "llama-3"},
|
|
},
|
|
}
|
|
if err := saveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("saveConfig: %v", err)
|
|
}
|
|
|
|
if err := unsetCustomProvider(configPath, "my-gateway"); err != nil {
|
|
t.Fatalf("unsetCustomProvider: %v", err)
|
|
}
|
|
|
|
cfg, err := loadOrCreateConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("reload: %v", err)
|
|
}
|
|
if cfg.CustomProviders != nil {
|
|
t.Errorf("CustomProviders should be nil after deleting the only entry, got %v", cfg.CustomProviders)
|
|
}
|
|
if cfg.Provider != "anthropic" {
|
|
t.Errorf("Provider = %q, want %q (should be untouched)", cfg.Provider, "anthropic")
|
|
}
|
|
}
|
|
|
|
func TestUnsetActiveCustomProvider(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := dir + "/config.json"
|
|
|
|
cfg := &Config{
|
|
Provider: "my-gateway",
|
|
Model: "fallback-model",
|
|
CustomProviders: map[string]ProviderEntry{
|
|
"my-gateway": {URL: "https://gw.example.com/v1", Protocol: "openai", Model: "llama-3"},
|
|
"other-gateway": {URL: "https://other.example.com/v1", Protocol: "openai", Model: "other-model"},
|
|
},
|
|
}
|
|
if err := saveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("saveConfig: %v", err)
|
|
}
|
|
|
|
if err := unsetCustomProvider(configPath, "my-gateway"); err != nil {
|
|
t.Fatalf("unsetCustomProvider: %v", err)
|
|
}
|
|
|
|
cfg, err := loadOrCreateConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("reload: %v", err)
|
|
}
|
|
if cfg.Provider != "" {
|
|
t.Errorf("Provider = %q, want empty after deleting active provider", cfg.Provider)
|
|
}
|
|
if cfg.Model != "" {
|
|
t.Errorf("Model = %q, want empty after deleting active provider", cfg.Model)
|
|
}
|
|
if _, exists := cfg.CustomProviders["my-gateway"]; exists {
|
|
t.Error("my-gateway should have been deleted")
|
|
}
|
|
if _, exists := cfg.CustomProviders["other-gateway"]; !exists {
|
|
t.Error("other-gateway should still exist")
|
|
}
|
|
}
|
|
|
|
func TestUnsetInvalidKey(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
wantErr bool
|
|
}{
|
|
{"my-gateway", false},
|
|
{"nonexistent", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := dir + "/config.json"
|
|
cfg := &Config{
|
|
CustomProviders: map[string]ProviderEntry{
|
|
"my-gateway": {URL: "https://gw.example.com/v1"},
|
|
},
|
|
}
|
|
if err := saveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("saveConfig: %v", err)
|
|
}
|
|
err := unsetCustomProvider(configPath, tt.name)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("unsetCustomProvider(%q): err=%v, wantErr=%v", tt.name, err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMergeModelLists(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
lists [][]string
|
|
want []string
|
|
}{
|
|
{"empty", nil, nil},
|
|
{"single list", [][]string{{"a", "b"}}, []string{"a", "b"}},
|
|
{"merge with dedup", [][]string{{"a", "b"}, {"b", "c"}}, []string{"a", "b", "c"}},
|
|
{"three lists", [][]string{{"x"}, {"y"}, {"x", "z"}}, []string{"x", "y", "z"}},
|
|
{"empty strings filtered", [][]string{{"a", "", "b"}}, []string{"a", "b"}},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := mergeModelLists(tc.lists...)
|
|
if len(got) != len(tc.want) {
|
|
t.Fatalf("mergeModelLists() = %v, want %v", got, tc.want)
|
|
}
|
|
for i := range tc.want {
|
|
if got[i] != tc.want[i] {
|
|
t.Errorf("[%d] = %q, want %q", i, got[i], tc.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_Command(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.command", "npx"); err != nil {
|
|
t.Fatalf("setMCPServerValue: %v", err)
|
|
}
|
|
if cfg.MCPServers["my-server"].Command != "npx" {
|
|
t.Errorf("Command = %q, want %q", cfg.MCPServers["my-server"].Command, "npx")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_CommandEmpty(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.command", ""); err == nil {
|
|
t.Fatal("expected error for empty command")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_Args(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.args", `["--port","8080"]`); err != nil {
|
|
t.Fatalf("setMCPServerValue: %v", err)
|
|
}
|
|
args := cfg.MCPServers["my-server"].Args
|
|
if len(args) != 2 || args[0] != "--port" || args[1] != "8080" {
|
|
t.Errorf("Args = %v", args)
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_ArgsInvalidJSON(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.args", "not-json"); err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_Env(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.env", `["FOO=bar","BAZ=qux"]`); err != nil {
|
|
t.Fatalf("setMCPServerValue: %v", err)
|
|
}
|
|
env := cfg.MCPServers["my-server"].Env
|
|
if len(env) != 2 || env[0] != "FOO=bar" {
|
|
t.Errorf("Env = %v", env)
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_EnvInvalidJSON(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.env", "not-json"); err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_EnvInvalidFormat(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.env", `["NOEQUALS"]`); err == nil {
|
|
t.Fatal("expected error for env entry without KEY=VALUE format")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_Tools(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.tools", `["search","read","search"]`); err != nil {
|
|
t.Fatalf("setMCPServerValue: %v", err)
|
|
}
|
|
tools := cfg.MCPServers["my-server"].Tools
|
|
if len(tools) != 2 || tools[0] != "search" || tools[1] != "read" {
|
|
t.Errorf("Tools = %v (expected deduped)", tools)
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_ToolsInvalidJSON(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.tools", "not-json"); err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_ToolsEmptyName(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.tools", `["search",""]`); err == nil {
|
|
t.Fatal("expected error for empty tool name")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_Setup(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.setup", "init-script.sh"); err != nil {
|
|
t.Fatalf("setMCPServerValue: %v", err)
|
|
}
|
|
if cfg.MCPServers["my-server"].Setup != "init-script.sh" {
|
|
t.Errorf("Setup = %q", cfg.MCPServers["my-server"].Setup)
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_UnknownField(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.my-server.unknown", "val"); err == nil {
|
|
t.Fatal("expected error for unknown field")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_InvalidKey(t *testing.T) {
|
|
cfg := &Config{}
|
|
tests := []string{
|
|
"mcp_servers",
|
|
"mcp_servers.",
|
|
"mcp_servers..command",
|
|
"mcp_servers.name",
|
|
}
|
|
for _, key := range tests {
|
|
if err := setMCPServerValue(cfg, key, "val"); err == nil {
|
|
t.Errorf("expected error for key %q", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_ExistingServer(t *testing.T) {
|
|
cfg := &Config{
|
|
MCPServers: map[string]MCPServerConfig{
|
|
"srv": {Command: "old-cmd"},
|
|
},
|
|
}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.srv.command", "new-cmd"); err != nil {
|
|
t.Fatalf("setMCPServerValue: %v", err)
|
|
}
|
|
if cfg.MCPServers["srv"].Command != "new-cmd" {
|
|
t.Errorf("Command = %q, want %q", cfg.MCPServers["srv"].Command, "new-cmd")
|
|
}
|
|
}
|
|
|
|
func TestUnsetMCPServer(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := dir + "/config.json"
|
|
|
|
cfg := &Config{
|
|
MCPServers: map[string]MCPServerConfig{
|
|
"srv1": {Command: "cmd1"},
|
|
"srv2": {Command: "cmd2"},
|
|
},
|
|
}
|
|
if err := saveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("saveConfig: %v", err)
|
|
}
|
|
|
|
if err := unsetMCPServer(configPath, "srv1"); err != nil {
|
|
t.Fatalf("unsetMCPServer: %v", err)
|
|
}
|
|
|
|
cfg, err := loadOrCreateConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("reload: %v", err)
|
|
}
|
|
if _, exists := cfg.MCPServers["srv1"]; exists {
|
|
t.Error("srv1 should have been deleted")
|
|
}
|
|
if _, exists := cfg.MCPServers["srv2"]; !exists {
|
|
t.Error("srv2 should still exist")
|
|
}
|
|
}
|
|
|
|
func TestUnsetMCPServer_LastEntry(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := dir + "/config.json"
|
|
|
|
cfg := &Config{
|
|
MCPServers: map[string]MCPServerConfig{
|
|
"only": {Command: "cmd"},
|
|
},
|
|
}
|
|
if err := saveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("saveConfig: %v", err)
|
|
}
|
|
|
|
if err := unsetMCPServer(configPath, "only"); err != nil {
|
|
t.Fatalf("unsetMCPServer: %v", err)
|
|
}
|
|
|
|
cfg, err := loadOrCreateConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("reload: %v", err)
|
|
}
|
|
if cfg.MCPServers != nil {
|
|
t.Errorf("MCPServers should be nil after deleting last entry, got %v", cfg.MCPServers)
|
|
}
|
|
}
|
|
|
|
func TestUnsetMCPServer_NotFound(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := dir + "/config.json"
|
|
|
|
cfg := &Config{}
|
|
if err := saveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("saveConfig: %v", err)
|
|
}
|
|
|
|
if err := unsetMCPServer(configPath, "nonexistent"); err == nil {
|
|
t.Fatal("expected error for nil MCPServers")
|
|
}
|
|
|
|
cfg = &Config{
|
|
MCPServers: map[string]MCPServerConfig{
|
|
"other": {Command: "cmd"},
|
|
},
|
|
}
|
|
if err := saveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("saveConfig: %v", err)
|
|
}
|
|
|
|
if err := unsetMCPServer(configPath, "nonexistent"); err == nil {
|
|
t.Fatal("expected error for missing server")
|
|
}
|
|
}
|
|
|
|
func TestRunConfigUnset_UnknownPrefix(t *testing.T) {
|
|
if err := runConfigUnset("providers.anthropic"); err == nil {
|
|
t.Fatal("expected error for unsupported prefix")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueMCPServer(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "mcp_servers.my-server.command", "npx"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.MCPServers["my-server"].Command != "npx" {
|
|
t.Errorf("Command = %q", cfg.MCPServers["my-server"].Command)
|
|
}
|
|
}
|
|
|
|
func TestEnsureTelemetry(t *testing.T) {
|
|
cfg := &Config{}
|
|
if cfg.Telemetry != nil {
|
|
t.Fatal("Telemetry should be nil initially")
|
|
}
|
|
cfg.ensureTelemetry()
|
|
if cfg.Telemetry == nil {
|
|
t.Fatal("Telemetry should be non-nil after ensureTelemetry()")
|
|
}
|
|
cfg.ensureTelemetry()
|
|
if cfg.Telemetry == nil {
|
|
t.Fatal("Telemetry should remain non-nil on second call")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmURL(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "llm.url", "https://example.com/v1"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Llm.URL != "https://example.com/v1" {
|
|
t.Errorf("URL = %q", cfg.Llm.URL)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmAuthToken(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "llm.auth_token", "tok-123"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Llm.AuthToken != "tok-123" {
|
|
t.Errorf("AuthToken = %q", cfg.Llm.AuthToken)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmModel(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "llm.model", "my-model"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Llm.Model != "my-model" {
|
|
t.Errorf("Model = %q", cfg.Llm.Model)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmUseAnthropic(t *testing.T) {
|
|
// use_anthropic mirrors protocol so the two never disagree.
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
wantUseAnthro bool
|
|
wantProtocol string
|
|
}{
|
|
{"true mirrors anthropic", "true", true, llm.ProtocolAnthropic},
|
|
{"false mirrors openai", "false", false, llm.ProtocolOpenAIChatCompletions},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "llm.use_anthropic", tt.value); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Llm.UseAnthropic == nil || *cfg.Llm.UseAnthropic != tt.wantUseAnthro {
|
|
got := "<nil>"
|
|
if cfg.Llm.UseAnthropic != nil {
|
|
got = strconv.FormatBool(*cfg.Llm.UseAnthropic)
|
|
}
|
|
t.Errorf("UseAnthropic = %s, want %v", got, tt.wantUseAnthro)
|
|
}
|
|
if cfg.Llm.Protocol != tt.wantProtocol {
|
|
t.Errorf("Protocol = %q, want %q", cfg.Llm.Protocol, tt.wantProtocol)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("overwrites stale protocol when switching use_anthropic", func(t *testing.T) {
|
|
// Simulate a prior openai-responses config; setting use_anthropic=true
|
|
// must repoint protocol to anthropic so they never disagree.
|
|
cfg := &Config{Llm: LlmConfig{Protocol: llm.ProtocolOpenAIResponses}}
|
|
if err := setConfigValue(cfg, "llm.use_anthropic", "true"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Llm.Protocol != llm.ProtocolAnthropic {
|
|
t.Errorf("Protocol = %q, want %q", cfg.Llm.Protocol, llm.ProtocolAnthropic)
|
|
}
|
|
})
|
|
|
|
t.Run("preserves openai-responses when setting use_anthropic false", func(t *testing.T) {
|
|
// A prior openai-responses config must not be silently downgraded to
|
|
// openai when the legacy use_anthropic=false is set.
|
|
cfg := &Config{Llm: LlmConfig{Protocol: llm.ProtocolOpenAIResponses}}
|
|
if err := setConfigValue(cfg, "llm.use_anthropic", "false"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Llm.Protocol != llm.ProtocolOpenAIResponses {
|
|
t.Errorf("Protocol = %q, want %q (openai-responses must be preserved)", cfg.Llm.Protocol, llm.ProtocolOpenAIResponses)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSetConfigValueLlmUseAnthropicInvalid(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "llm.use_anthropic", "notbool"); err == nil {
|
|
t.Fatal("expected error for invalid boolean")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLanguage(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "language", "English"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Language != "English" {
|
|
t.Errorf("Language = %q", cfg.Language)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueTelemetryEnabled(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "telemetry.enabled", "true"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Telemetry == nil || !cfg.Telemetry.Enabled {
|
|
t.Error("Telemetry.Enabled should be true")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueTelemetryEnabledInvalid(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "telemetry.enabled", "notbool"); err == nil {
|
|
t.Fatal("expected error for invalid boolean")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueTelemetryExporter(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "telemetry.exporter", "otlp"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Telemetry.Exporter != "otlp" {
|
|
t.Errorf("Exporter = %q", cfg.Telemetry.Exporter)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueTelemetryOTLPEndpoint(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "telemetry.otlp_endpoint", "localhost:4317"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Telemetry.OTLPEndpoint != "localhost:4317" {
|
|
t.Errorf("OTLPEndpoint = %q", cfg.Telemetry.OTLPEndpoint)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueTelemetryContentLogging(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "telemetry.content_logging", "true"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if !cfg.Telemetry.ContentLog {
|
|
t.Error("ContentLog should be true")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueTelemetryContentLoggingInvalid(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "telemetry.content_logging", "notbool"); err == nil {
|
|
t.Fatal("expected error for invalid boolean")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmExtraBody(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "llm.extra_body", `{"key":"val"}`); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Llm.ExtraBody == nil {
|
|
t.Fatal("ExtraBody should not be nil")
|
|
}
|
|
if cfg.Llm.ExtraBody["key"] != "val" {
|
|
t.Errorf("ExtraBody[\"key\"] = %v", cfg.Llm.ExtraBody["key"])
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmExtraBodyInvalid(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "llm.extra_body", "not-json"); err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueUnknownKey(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setConfigValue(cfg, "unknown.key", "val"); err == nil {
|
|
t.Fatal("expected error for unknown key")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueUnknownKeyMessage(t *testing.T) {
|
|
// The unknown-key error message must stay byte-identical after extracting
|
|
// supportedConfigKeys, and must be generated from that list.
|
|
err := setConfigValue(&Config{}, "bogus.key", "val")
|
|
if err == nil {
|
|
t.Fatal("expected error for unknown key")
|
|
}
|
|
want := "unknown config key: bogus.key\n" +
|
|
"Supported keys: provider, model, max_tokens, providers.<name>.<field>, custom_providers.<name>.<field>, mcp_servers.<name>.<field>, llm.url, llm.auth_token, llm.auth_token_cmd, llm.auth_header, llm.model, llm.protocol, llm.use_anthropic, llm.extra_body, llm.extra_headers, llm.retry_codes, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging\n" +
|
|
"Provider fields: api_key, api_key_cmd, url, protocol, model, models, auth_header, extra_body, extra_headers, retry_codes, aws_region, aws_profile\n" +
|
|
"Protocol values: anthropic, anthropic-bedrock, openai, openai-responses\n" +
|
|
"MCP server fields: type, command, args, env, url, headers, tools, setup"
|
|
if err.Error() != want {
|
|
t.Errorf("unknown-key message drifted:\n got: %q\nwant: %q", err.Error(), want)
|
|
}
|
|
if !strings.Contains(err.Error(), strings.Join(supportedConfigKeys, ", ")) {
|
|
t.Error("message should be generated from supportedConfigKeys")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderClearsModel(t *testing.T) {
|
|
cfg := &Config{Provider: "old-provider", Model: "old-model"}
|
|
if err := setConfigValue(cfg, "provider", "new-provider"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if cfg.Model != "" {
|
|
t.Errorf("Model should be cleared on provider change, got %q", cfg.Model)
|
|
}
|
|
}
|
|
|
|
func TestRunConfigUnset_InvalidKey(t *testing.T) {
|
|
if err := runConfigUnset("custom_providers."); err == nil {
|
|
t.Fatal("expected error for empty provider name")
|
|
}
|
|
}
|
|
|
|
func TestRunConfigSetWarnsWhenActiveProviderShadowsLegacyLLMConfig(t *testing.T) {
|
|
t.Setenv("HOME", t.TempDir())
|
|
configPath, err := defaultConfigPath()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := saveConfig(configPath, &Config{Provider: "dashscope"}); err != nil {
|
|
t.Fatalf("save config: %v", err)
|
|
}
|
|
|
|
stderr := captureConfigStderr(t, func() {
|
|
if err := runConfigSet("llm.url", "https://gateway.example/v1"); err != nil {
|
|
t.Fatalf("runConfigSet: %v", err)
|
|
}
|
|
})
|
|
if !strings.Contains(stderr, `provider "dashscope" is active`) {
|
|
t.Errorf("warning = %q", stderr)
|
|
}
|
|
if !strings.Contains(stderr, "providers.dashscope.<field>") || !strings.Contains(stderr, "config unset provider") {
|
|
t.Errorf("warning does not explain how to resolve precedence: %q", stderr)
|
|
}
|
|
|
|
cfg, err := loadOrCreateConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("reload config: %v", err)
|
|
}
|
|
if cfg.Llm.URL != "https://gateway.example/v1" {
|
|
t.Errorf("llm.url = %q", cfg.Llm.URL)
|
|
}
|
|
}
|
|
|
|
func TestLegacyLLMShadowWarning(t *testing.T) {
|
|
if got := legacyLLMShadowWarning("", "llm.model"); got != "" {
|
|
t.Errorf("warning without active provider = %q", got)
|
|
}
|
|
if got := legacyLLMShadowWarning("dashscope", "providers.dashscope.url"); got != "" {
|
|
t.Errorf("warning for provider setting = %q", got)
|
|
}
|
|
if got := legacyLLMShadowWarning("dashscope", "Llm.model"); got != "" {
|
|
t.Errorf("warning for invalid mixed-case legacy key = %q", got)
|
|
}
|
|
if got := legacyLLMShadowWarning("dashscope", "llm.model"); !strings.Contains(got, "providers.dashscope.<field>") {
|
|
t.Errorf("preset-provider warning = %q", got)
|
|
}
|
|
if got := legacyLLMShadowWarning("my-gateway", "llm.model"); !strings.Contains(got, "custom_providers.my-gateway.<field>") {
|
|
t.Errorf("custom-provider warning = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunConfigUnsetProviderClearsSelectionAndKeepsProviderEntries(t *testing.T) {
|
|
t.Setenv("HOME", t.TempDir())
|
|
configPath, err := defaultConfigPath()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := saveConfig(configPath, &Config{
|
|
Provider: "dashscope",
|
|
Model: "legacy-model",
|
|
Providers: map[string]ProviderEntry{
|
|
"dashscope": {APIKey: "secret", Model: "provider-model"},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save config: %v", err)
|
|
}
|
|
|
|
if err := runConfigUnset("provider"); err != nil {
|
|
t.Fatalf("runConfigUnset(provider): %v", err)
|
|
}
|
|
cfg, err := loadOrCreateConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("reload config: %v", err)
|
|
}
|
|
if cfg.Provider != "" || cfg.Model != "" {
|
|
t.Errorf("provider/model = %q/%q, want both empty", cfg.Provider, cfg.Model)
|
|
}
|
|
if got := cfg.Providers["dashscope"].APIKey; got != "secret" {
|
|
t.Errorf("provider entry was removed or changed: api_key = %q", got)
|
|
}
|
|
}
|
|
|
|
func captureConfigStderr(t *testing.T, fn func()) string {
|
|
t.Helper()
|
|
old := os.Stderr
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
os.Stderr = w
|
|
defer func() { os.Stderr = old }()
|
|
|
|
// Drained concurrently: reading only after fn returns caps the capture at the
|
|
// OS pipe buffer (64 KiB on Linux, far less on a Windows anonymous pipe) and
|
|
// a payload past that blocks the writer forever.
|
|
var data []byte
|
|
var readErr error
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
data, readErr = io.ReadAll(r)
|
|
}()
|
|
fn()
|
|
if err := w.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
<-done
|
|
if readErr != nil {
|
|
t.Fatal(readErr)
|
|
}
|
|
if err := r.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func TestRunConfig_EmptyArgs(t *testing.T) {
|
|
err := runConfig(nil)
|
|
if err != nil {
|
|
t.Fatalf("runConfig with nil args should print usage, got error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunConfig_ProviderWithArgs(t *testing.T) {
|
|
err := runConfig([]string{"provider", "extra"})
|
|
if err == nil {
|
|
t.Fatal("expected error when provider has args")
|
|
}
|
|
}
|
|
|
|
func TestRunConfig_ModelWithArgs(t *testing.T) {
|
|
err := runConfig([]string{"model", "extra"})
|
|
if err == nil {
|
|
t.Fatal("expected error when model has args")
|
|
}
|
|
}
|
|
|
|
func TestDeleteCustomProvider_NotFound(t *testing.T) {
|
|
cfg := &Config{}
|
|
_, err := deleteCustomProvider(cfg, "nonexistent")
|
|
if err == nil {
|
|
t.Fatal("expected error for nil CustomProviders")
|
|
}
|
|
|
|
cfg.CustomProviders = map[string]ProviderEntry{"other": {}}
|
|
_, err = deleteCustomProvider(cfg, "nonexistent")
|
|
if err == nil {
|
|
t.Fatal("expected error for missing provider")
|
|
}
|
|
}
|
|
|
|
func TestActiveModelForProvider(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
cfg *Config
|
|
provider string
|
|
entry ProviderEntry
|
|
want string
|
|
}{
|
|
{"entry model", nil, "p", ProviderEntry{Model: "m1"}, "m1"},
|
|
{"cfg model", &Config{Provider: "p", Model: "m2"}, "p", ProviderEntry{}, "m2"},
|
|
{"entry takes precedence", &Config{Provider: "p", Model: "m2"}, "p", ProviderEntry{Model: "m1"}, "m1"},
|
|
{"different provider", &Config{Provider: "other", Model: "m2"}, "p", ProviderEntry{}, ""},
|
|
{"no model", &Config{Provider: "p"}, "p", ProviderEntry{}, ""},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := activeModelForProvider(tc.cfg, tc.provider, tc.entry)
|
|
if got != tc.want {
|
|
t.Errorf("got %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNormalizeModelList(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
models []string
|
|
want []string
|
|
}{
|
|
{"dedup", []string{"a", "b", "a"}, []string{"a", "b"}},
|
|
{"trim spaces", []string{" a ", " b "}, []string{"a", "b"}},
|
|
{"filter empty", []string{"a", "", "b"}, []string{"a", "b"}},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := normalizeModelList(tc.models)
|
|
if len(got) != len(tc.want) {
|
|
t.Fatalf("got %v, want %v", got, tc.want)
|
|
}
|
|
for i := range tc.want {
|
|
if got[i] != tc.want[i] {
|
|
t.Errorf("[%d] = %q, want %q", i, got[i], tc.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseModelListValue(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
want int
|
|
}{
|
|
{"empty", "", 0},
|
|
{"json array", `["a","b"]`, 2},
|
|
{"comma separated", "a,b,c", 3},
|
|
{"bracket unquoted", "[a,b]", 2},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := parseModelListValue(tc.value)
|
|
if err != nil {
|
|
t.Fatalf("parseModelListValue: %v", err)
|
|
}
|
|
if len(got) != tc.want {
|
|
t.Errorf("got %d models, want %d: %v", len(got), tc.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveConfigPath_Default(t *testing.T) {
|
|
t.Setenv("OCR_CONFIG_PATH", "")
|
|
p, err := resolveConfigPath()
|
|
if err != nil {
|
|
t.Fatalf("resolveConfigPath: %v", err)
|
|
}
|
|
if p == "" {
|
|
t.Fatal("expected non-empty default config path")
|
|
}
|
|
}
|
|
|
|
func TestResolveConfigPath_Env(t *testing.T) {
|
|
t.Setenv("OCR_CONFIG_PATH", "/tmp/test-config.json")
|
|
p, err := resolveConfigPath()
|
|
if err != nil {
|
|
t.Fatalf("resolveConfigPath: %v", err)
|
|
}
|
|
if p != "/tmp/test-config.json" {
|
|
t.Errorf("path = %q, want /tmp/test-config.json", p)
|
|
}
|
|
}
|
|
|
|
func TestLoadOrCreateConfig_NewFile(t *testing.T) {
|
|
cfg, err := loadOrCreateConfig(t.TempDir() + "/nonexistent.json")
|
|
if err != nil {
|
|
t.Fatalf("loadOrCreateConfig: %v", err)
|
|
}
|
|
if cfg == nil {
|
|
t.Fatal("expected non-nil config")
|
|
}
|
|
}
|
|
|
|
func TestLoadOrCreateConfig_InvalidJSON(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := dir + "/bad.json"
|
|
if err := os.WriteFile(path, []byte("{invalid"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := loadOrCreateConfig(path)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestLoadAppConfig_NotExist(t *testing.T) {
|
|
cfg, err := LoadAppConfig(t.TempDir() + "/none.json")
|
|
if err != nil {
|
|
t.Fatalf("LoadAppConfig: %v", err)
|
|
}
|
|
if cfg != nil {
|
|
t.Fatal("expected nil config for non-existent file")
|
|
}
|
|
}
|
|
|
|
func TestLoadAppConfig_InvalidJSON(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := dir + "/bad.json"
|
|
if err := os.WriteFile(path, []byte("not json"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := LoadAppConfig(path)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestEnsureModelInList(t *testing.T) {
|
|
models := []string{"test-model", "test-model-2", "bbb", "aaa", "test-model-3"}
|
|
|
|
got := ensureModelInList(models, "test-model-3")
|
|
if len(got) != len(models) {
|
|
t.Fatalf("existing model should not reorder: got %v", got)
|
|
}
|
|
for i := range models {
|
|
if got[i] != models[i] {
|
|
t.Errorf("models[%d] = %q, want %q", i, got[i], models[i])
|
|
}
|
|
}
|
|
|
|
got = ensureModelInList(models, "new-model")
|
|
want := append(append([]string(nil), models...), "new-model")
|
|
if len(got) != len(want) || got[len(got)-1] != "new-model" {
|
|
t.Errorf("new model should append: got %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
// TestConfigRoundTripPreservesTimeoutSec guards against silent config loss:
|
|
// the resolver reads providers.<name>.timeout_sec / llm.timeout_sec (the docs
|
|
// tell users to hand-edit them), but the cmd-side Config struct used to lack
|
|
// the field, so any loadOrCreateConfig + saveConfig cycle (every
|
|
// `ocr config set`, `ocr config model`, interactive provider setup, ...)
|
|
// silently dropped the key and reverted requests to the default timeout.
|
|
func TestConfigRoundTripPreservesTimeoutSec(t *testing.T) {
|
|
configPath := t.TempDir() + "/config.json"
|
|
original := `{
|
|
"provider": "ollama",
|
|
"providers": {
|
|
"ollama": {
|
|
"url": "http://127.0.0.1:11434/v1",
|
|
"protocol": "openai",
|
|
"model": "qwen3",
|
|
"timeout_sec": 900
|
|
}
|
|
},
|
|
"custom_providers": {
|
|
"my-gateway": {
|
|
"url": "https://gw.example.com/v1",
|
|
"protocol": "openai",
|
|
"timeout_sec": 120
|
|
}
|
|
},
|
|
"llm": {
|
|
"timeout_sec": 60
|
|
}
|
|
}`
|
|
if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
|
|
// Emulate an unrelated `ocr config set` round-trip.
|
|
cfg, err := loadOrCreateConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("loadOrCreateConfig: %v", err)
|
|
}
|
|
if err := setConfigValue(cfg, "language", "Chinese"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
if err := saveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("saveConfig: %v", err)
|
|
}
|
|
|
|
reloaded, err := loadOrCreateConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("reload: %v", err)
|
|
}
|
|
if got := reloaded.Providers["ollama"].TimeoutSec; got != 900 {
|
|
t.Errorf("providers.ollama.timeout_sec = %d, want 900 (lost in round-trip)", got)
|
|
}
|
|
if got := reloaded.CustomProviders["my-gateway"].TimeoutSec; got != 120 {
|
|
t.Errorf("custom_providers.my-gateway.timeout_sec = %d, want 120 (lost in round-trip)", got)
|
|
}
|
|
if got := reloaded.Llm.TimeoutSec; got != 60 {
|
|
t.Errorf("llm.timeout_sec = %d, want 60 (lost in round-trip)", got)
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_Type(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.gh.type", "remote"); err != nil {
|
|
t.Fatalf("setMCPServerValue: %v", err)
|
|
}
|
|
if cfg.MCPServers["gh"].Type != "remote" {
|
|
t.Errorf("Type = %q, want %q", cfg.MCPServers["gh"].Type, "remote")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_TypeInvalid(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.gh.type", "invalid"); err == nil {
|
|
t.Fatal("expected error for invalid type, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_URL(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.gh.url", "https://api.example.com/mcp"); err != nil {
|
|
t.Fatalf("setMCPServerValue: %v", err)
|
|
}
|
|
if cfg.MCPServers["gh"].URL != "https://api.example.com/mcp" {
|
|
t.Errorf("URL = %q, want %q", cfg.MCPServers["gh"].URL, "https://api.example.com/mcp")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_URLEmpty(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.gh.url", ""); err == nil {
|
|
t.Fatal("expected error for empty URL, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_URLInvalidScheme(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.gh.url", "ftp://example.com/mcp"); err == nil {
|
|
t.Fatal("expected error for non-http scheme, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_Headers(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.gh.headers", `{"Authorization":"Bearer $TOKEN","X-Custom":"val"}`); err != nil {
|
|
t.Fatalf("setMCPServerValue: %v", err)
|
|
}
|
|
h := cfg.MCPServers["gh"].Headers
|
|
if h["Authorization"] != "Bearer $TOKEN" {
|
|
t.Errorf("Authorization = %q, want %q", h["Authorization"], "Bearer $TOKEN")
|
|
}
|
|
if h["X-Custom"] != "val" {
|
|
t.Errorf("X-Custom = %q, want %q", h["X-Custom"], "val")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_URLNoHost(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.gh.url", "http://"); err == nil {
|
|
t.Fatal("expected error for URL without host, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_URLParseError(t *testing.T) {
|
|
cfg := &Config{}
|
|
// "://bad" has no scheme, so url.Parse itself fails before the scheme check.
|
|
if err := setMCPServerValue(cfg, "mcp_servers.gh.url", "://bad"); err == nil {
|
|
t.Fatal("expected error for unparseable URL, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_HeadersEmptyName(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.gh.headers", `{"":"val"}`); err == nil {
|
|
t.Fatal("expected error for empty header name, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_HeadersInvalidJSON(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.gh.headers", "not-json"); err == nil {
|
|
t.Fatal("expected error for invalid JSON, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSetMCPServerValue_HeadersEmptyValue(t *testing.T) {
|
|
cfg := &Config{}
|
|
if err := setMCPServerValue(cfg, "mcp_servers.gh.headers", `{"Authorization":""}`); err == nil {
|
|
t.Fatal("expected error for empty header value, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmRetryCodesRedundantWarning(t *testing.T) {
|
|
cfg := &Config{}
|
|
stderr := captureConfigStderr(t, func() {
|
|
if err := setConfigValue(cfg, "llm.retry_codes", "429,403"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
})
|
|
if !strings.Contains(stderr, "WARNING") || !strings.Contains(stderr, "429") {
|
|
t.Errorf("expected warning about 429 on stderr, got %q", stderr)
|
|
}
|
|
if len(cfg.Llm.RetryCodes) != 1 || cfg.Llm.RetryCodes[0] != 403 {
|
|
t.Errorf("RetryCodes = %v, want [403]", cfg.Llm.RetryCodes)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueProviderRetryCodesRedundantWarning(t *testing.T) {
|
|
cfg := &Config{}
|
|
stderr := captureConfigStderr(t, func() {
|
|
if err := setConfigValue(cfg, "custom_providers.test.retry_codes", "408,400"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
})
|
|
if !strings.Contains(stderr, "WARNING") || !strings.Contains(stderr, "408") {
|
|
t.Errorf("expected warning about 408 on stderr, got %q", stderr)
|
|
}
|
|
entry := cfg.CustomProviders["test"]
|
|
if len(entry.RetryCodes) != 1 || entry.RetryCodes[0] != 400 {
|
|
t.Errorf("RetryCodes = %v, want [400]", entry.RetryCodes)
|
|
}
|
|
}
|
|
|
|
func TestSetConfigValueLlmRetryCodesNoWarningForValidCodes(t *testing.T) {
|
|
cfg := &Config{}
|
|
stderr := captureConfigStderr(t, func() {
|
|
if err := setConfigValue(cfg, "llm.retry_codes", "403,400"); err != nil {
|
|
t.Fatalf("setConfigValue: %v", err)
|
|
}
|
|
})
|
|
if stderr != "" {
|
|
t.Errorf("expected no stderr output, got %q", stderr)
|
|
}
|
|
if len(cfg.Llm.RetryCodes) != 2 {
|
|
t.Errorf("RetryCodes = %v, want [403 400]", cfg.Llm.RetryCodes)
|
|
}
|
|
}
|