mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* fix(providers): register Atomic Chat as local OpenAI-compatible runtime Point atomic-chat at the default local API (http://127.0.0.1:1337/v1), mark it Local with no API key, and auto-detect it alongside Ollama and LM Studio. Keep opencode-go-anthropic-compatible in the provider catalog and curated model list after rebasing onto latest main. Co-authored-by: Cursor <[email protected]> * docs(zh): sync Atomic Chat local runtime into README_ZH The English README lists Atomic Chat in the provider list and tells local-model users to run its desktop app on http://127.0.0.1:1337/v1, but the Chinese README still mentioned only Ollama and LM Studio. Co-Authored-By: Claude Opus 4.8 <[email protected]> * fix(providers): give the local Atomic Chat runtime its own catalog ID Repurposing the existing atomic-chat ID from the hosted, key-gated preset to a keyless local one silently downgraded credential checks for profiles already saved against the remote endpoint: MissingCredentialEnv resolves RequiresAuth from the catalog descriptor, so those profiles were reported as ready and only failed later with an unauthenticated call to api.atomic.chat. Keep the hosted atomic-chat descriptor as it was and register the local runtime under a separate atomic-chat-local ID, mirroring the existing ollama-cloud/ollama split. Stored profiles keep their identity and no migration is needed. Co-Authored-By: Claude Opus 4.8 <[email protected]> * test(onboarding): assert the full Atomic Chat local endpoint and default model Co-Authored-By: Claude Opus 4.8 <[email protected]> * fix(models): drop the placeholder curated list for atomic-chat-local Atomic Chat's model catalog is a Hugging Face scrape refreshed every 12h plus arbitrary owner/repo lookups, so the served model ID is whichever model the user loaded and no hardcoded list stays accurate. The single local-model entry short-circuited Models() without adding anything: the descriptor is openai-compatible, so modelMatchesProvider never matches a registry entry and the fallback already yields the descriptor's DefaultModel. Output is unchanged; the difference is that local-model is no longer presented as a curated contract. Co-Authored-By: Claude Opus 4.8 <[email protected]> * fix(onboarding): pin the probed model in the local-runtime adopt command probeLocalRuntime already reads the runtime's /v1/models, but SetupAction dropped the result and emitted 'zero providers add <id> --set-active' with no --model. The add path then persisted the catalog DefaultModel, which for a local runtime is the placeholder 'local-model', and the first completion failed with an unknown-model response. SetupAction now pins the probed id via SetupCommandWithModel. The catalog default wins when the server actually serves it, so an existing Ollama setup does not switch models; otherwise the first advertised id is used. An empty probe result leaves the command on the catalog default rather than inventing an id. Covers a server whose returned id is not 'local-model'. Co-Authored-By: Claude Opus 4.8 <[email protected]> * fix(onboarding): shell-safe adopt command + reject atomic-chat-local placeholder model commandArg now POSIX single-quotes rendered args instead of strconv.Quote, so a model id from an untrusted /v1/models response cannot expand when the adopt command is pasted into a shell. providerProfileForAdd now errors when atomic-chat-local is added without --model instead of persisting the local-model placeholder that fails the first completion. Adds regression tests for command substitution, backticks, and the require-model guard. * fix(onboarding): finish Atomic Chat setup across supported shells Rebase on current main and keep the hosted Atomic Chat preset intact. Only emit setup arguments that are portable across POSIX, cmd.exe and PowerShell; show interactive guidance for other model IDs. Reject the Atomic Chat placeholder before both CLI and TUI persistence, and stop advertising adoption when discovery finds no usable model. Validation: full Go test suite, focused race suite, vet, release build, smoke, formatting, advisory lint and vulncheck pass with isolated config and inherited Anthropic overrides removed. Onboarding, CLI and TUI test packages cross-compile for Windows and Linux. Regression tests fail on the rebased pre-fix code and pass with these changes. Refs Gitlawb/zero#550 and Gitlawb/zero#592. * fix(onboarding): preserve option-looking model IDs in adopt commands Use the existing --model=value grammar when a discovered model begins with a hyphen. Validate the model separately from the fixed separator so shell safety remains unchanged and '=' is not added to the allowlist. Exercise generated detection actions through shell tokenization, the actual add parser, and profile construction for Atomic Chat, LM Studio and Ollama. Cover leading hyphens, flag-looking IDs, ordinary IDs, spaces, and unsafe inline values. Before the fix, the new regression failed with: generated adoption command rejected by add parser: --model requires a value Validation: full Go suite, focused race tests, formatting, vet, release build, smoke, lint-static and vulncheck passed. Affected test packages cross-compiled for Windows and Linux. Refs Gitlawb/zero#550. * fix(onboarding): select eligible chat models and preserve Ollama defaults P2: apply the existing known-noncoding exclusion before default preference or first-model fallback. When a nonempty list has no eligible model, offer commandless guidance instead of persisting the rejected/default model. P3: recognize an Ollama untagged default's :latest spelling, preserving the advertised ID. Prefer exact matches and leave other tags/providers alone. Add selector tests plus a mock-server integration test through discovery, the generated action, the real add parser, profile persistence, a fresh process, and the chat completion request. Before this fix, that test failed: fresh process sent model "text-embedding-local", want eligible model "-loaded chat model" The Ollama alias case also failed before the fix: AdoptModel() = "qwen3:8b", want "llama3.1:latest" Explicit model choices, shell checks, hosted/local provider identities and Atomic placeholder guards retain their existing behavior. Validation: full Go suite, focused race tests, formatting, vet, release build, smoke, lint-static and vulncheck passed. Affected test packages cross-compiled for Windows and Linux. Integration evidence uses a mock server; native Windows shell and actual runtime behavior are not claimed. Refs Gitlawb/zero#550 and Gitlawb/zero#592. --------- Co-authored-by: Cursor <[email protected]> Co-authored-by: Claude Opus 4.8 <[email protected]> Co-authored-by: alex <[email protected]>
614 lines
19 KiB
Go
614 lines
19 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/Gitlawb/zero/internal/aimlapi"
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/providercatalog"
|
|
"github.com/Gitlawb/zero/internal/providerhealth"
|
|
"github.com/Gitlawb/zero/internal/zerocommands"
|
|
)
|
|
|
|
type providerAddOptions struct {
|
|
catalogID string
|
|
name string
|
|
model string
|
|
baseURL string
|
|
apiKeyEnv string
|
|
authHeader string
|
|
authScheme string
|
|
authHeaderValue string
|
|
customHeaders map[string]string
|
|
setActive bool
|
|
json bool
|
|
}
|
|
|
|
type providerCheckOptions struct {
|
|
name string
|
|
json bool
|
|
connectivity bool
|
|
}
|
|
|
|
func runProvidersAdd(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
options, help, err := parseProviderAddArgs(args)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeProvidersHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
profile, err := providerProfileForAdd(options)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
configPath, err := deps.userConfigPath()
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), exitCrash)
|
|
}
|
|
// Persist with the key moved into the encrypted credential store (capture flip);
|
|
// the local profile keeps the key for the verification build below.
|
|
cfg, err := config.UpsertProvider(configPath, config.SecureProviderProfile(profile, configPath), options.setActive)
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), exitCrash)
|
|
}
|
|
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, map[string]any{
|
|
"configPath": configPath,
|
|
"activeProvider": cfg.ActiveProvider,
|
|
"provider": zerocommands.ProviderSnapshotFromProfile(profile, cfg.ActiveProvider == profile.Name),
|
|
}); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintf(stdout, "Added provider %s to %s\n", profile.Name, configPath); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func runProvidersCheck(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
options, help, err := parseProviderCheckArgs(args)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeProvidersHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
|
if exitCode != exitSuccess {
|
|
return exitCode
|
|
}
|
|
profile, err := selectProviderForCheck(resolved, options.name)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
var health providerhealth.Result
|
|
if options.connectivity {
|
|
ctx, stop := signalContext()
|
|
defer stop()
|
|
health = deps.probeProviderHealth(ctx, providerhealth.Options{
|
|
Profile: profile,
|
|
Connectivity: true,
|
|
UserAgent: userAgent(),
|
|
})
|
|
} else {
|
|
if err := validateProviderRuntimeReady(profile); err != nil {
|
|
return writeAppError(stderr, providerCheckErrorMessage(err, profile), exitProvider)
|
|
}
|
|
if _, err := deps.newProvider(profile); err != nil {
|
|
return writeAppError(stderr, err.Error(), exitProvider)
|
|
}
|
|
}
|
|
|
|
snapshot := zerocommands.ProviderSnapshotFromProfile(profile, profile.Name == resolved.ActiveProvider)
|
|
if options.json {
|
|
payload := map[string]any{"provider": snapshot, "status": "ok"}
|
|
if options.connectivity {
|
|
payload["health"] = health
|
|
payload["status"] = providerHealthStatusLabel(health.Status)
|
|
}
|
|
if nextActions := providerCheckNextActions(profile, options.connectivity, health); len(nextActions) > 0 {
|
|
payload["nextActions"] = nextActions
|
|
}
|
|
if err := writePrettyJSON(stdout, payload); err != nil {
|
|
return exitCrash
|
|
}
|
|
if options.connectivity && health.Status == providerhealth.StatusFail {
|
|
return exitProvider
|
|
}
|
|
return exitSuccess
|
|
}
|
|
status := "ok"
|
|
if options.connectivity {
|
|
status = providerHealthStatusLabel(health.Status)
|
|
}
|
|
if _, err := fmt.Fprintf(stdout, "Provider check\nname: %s\nstatus: %s\nkind: %s\nmodel: %s\napi model: %s\n", snapshot.Name, status, snapshot.ProviderKind, snapshot.Model, snapshot.APIModel); err != nil {
|
|
return exitCrash
|
|
}
|
|
if options.connectivity {
|
|
if _, err := fmt.Fprintf(stdout, "connectivity: %s\n", health.Status); err != nil {
|
|
return exitCrash
|
|
}
|
|
if check := health.PrimaryCheck(); check != nil {
|
|
if _, err := fmt.Fprintf(stdout, "%s: %s\n", check.ID, check.Message); err != nil {
|
|
return exitCrash
|
|
}
|
|
}
|
|
}
|
|
for _, nextAction := range providerCheckNextActions(profile, options.connectivity, health) {
|
|
if _, err := fmt.Fprintf(stdout, "next: %s\n", nextAction); err != nil {
|
|
return exitCrash
|
|
}
|
|
}
|
|
if options.connectivity && health.Status == providerhealth.StatusFail {
|
|
return exitProvider
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func providerHealthStatusLabel(status providerhealth.Status) string {
|
|
switch status {
|
|
case providerhealth.StatusFail:
|
|
return "fail"
|
|
case providerhealth.StatusWarn:
|
|
return "warn"
|
|
default:
|
|
return "ok"
|
|
}
|
|
}
|
|
|
|
func providerCheckErrorMessage(err error, profile config.ProviderProfile) string {
|
|
message := err.Error()
|
|
if nextAction := providerCheckMissingKeyNextAction(profile); nextAction != "" {
|
|
message += "\nnext: " + nextAction
|
|
}
|
|
return message
|
|
}
|
|
|
|
func providerCheckNextActions(profile config.ProviderProfile, connectivity bool, health providerhealth.Result) []string {
|
|
name := providerCheckName(profile)
|
|
if !connectivity {
|
|
return []string{fmt.Sprintf("run zero providers check %s --connectivity", name)}
|
|
}
|
|
switch health.Status {
|
|
case providerhealth.StatusFail:
|
|
return []string{fmt.Sprintf("verify the API key, base URL, and model, then rerun zero providers check %s --connectivity", name)}
|
|
case providerhealth.StatusWarn:
|
|
return []string{fmt.Sprintf("review the warning, then rerun zero providers check %s --connectivity", name)}
|
|
default:
|
|
model := strings.TrimSpace(profile.Model)
|
|
if model == "" {
|
|
return []string{"provider is ready"}
|
|
}
|
|
return []string{fmt.Sprintf("run zero exec %q --model %s", "hello", model)}
|
|
}
|
|
}
|
|
|
|
func providerCheckMissingKeyNextAction(profile config.ProviderProfile) string {
|
|
if providerProfileHasCredential(profile) {
|
|
return ""
|
|
}
|
|
apiKeyEnv := providerCheckAPIKeyEnv(profile)
|
|
if apiKeyEnv == "" {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("set %s and rerun zero providers check %s", apiKeyEnv, providerCheckName(profile))
|
|
}
|
|
|
|
func providerCheckAPIKeyEnv(profile config.ProviderProfile) string {
|
|
if apiKeyEnv := strings.TrimSpace(profile.APIKeyEnv); apiKeyEnv != "" {
|
|
return apiKeyEnv
|
|
}
|
|
if strings.TrimSpace(profile.CatalogID) == "" {
|
|
return ""
|
|
}
|
|
descriptor, err := providercatalog.Require(profile.CatalogID)
|
|
if err != nil || !descriptor.RequiresAuth || !providercatalog.RuntimeSupported(descriptor) {
|
|
return ""
|
|
}
|
|
for _, envVar := range descriptor.AuthEnvVars {
|
|
if envVar = strings.TrimSpace(envVar); envVar != "" {
|
|
return envVar
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func providerCheckName(profile config.ProviderProfile) string {
|
|
if name := strings.TrimSpace(profile.Name); name != "" {
|
|
return name
|
|
}
|
|
if catalogID := strings.TrimSpace(profile.CatalogID); catalogID != "" {
|
|
return catalogID
|
|
}
|
|
if provider := strings.TrimSpace(profile.Provider); provider != "" {
|
|
return provider
|
|
}
|
|
if kind := strings.TrimSpace(string(profile.ProviderKind)); kind != "" {
|
|
return kind
|
|
}
|
|
return "provider"
|
|
}
|
|
|
|
func parseProviderAddArgs(args []string) (providerAddOptions, bool, error) {
|
|
options := providerAddOptions{}
|
|
for index := 0; index < len(args); index++ {
|
|
arg := args[index]
|
|
switch {
|
|
case arg == "-h" || arg == "--help" || arg == "help":
|
|
return options, true, nil
|
|
case arg == "--json":
|
|
options.json = true
|
|
case arg == "--set-active":
|
|
options.setActive = true
|
|
case arg == "--name":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.name = value
|
|
index = next
|
|
case strings.HasPrefix(arg, "--name="):
|
|
value, err := requiredInlineFlagValue(arg, "--name")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.name = value
|
|
case arg == "--model":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.model = value
|
|
index = next
|
|
case strings.HasPrefix(arg, "--model="):
|
|
value, err := requiredInlineFlagValue(arg, "--model")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.model = value
|
|
case arg == "--base-url":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.baseURL = value
|
|
index = next
|
|
case strings.HasPrefix(arg, "--base-url="):
|
|
value, err := requiredInlineFlagValue(arg, "--base-url")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.baseURL = value
|
|
case arg == "--api-key-env":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.apiKeyEnv = value
|
|
index = next
|
|
case strings.HasPrefix(arg, "--api-key-env="):
|
|
value, err := requiredInlineFlagValue(arg, "--api-key-env")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.apiKeyEnv = value
|
|
case arg == "--auth-header":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.authHeader = value
|
|
index = next
|
|
case strings.HasPrefix(arg, "--auth-header="):
|
|
value, err := requiredInlineFlagValue(arg, "--auth-header")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.authHeader = value
|
|
case arg == "--auth-scheme":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.authScheme = value
|
|
index = next
|
|
case strings.HasPrefix(arg, "--auth-scheme="):
|
|
options.authScheme = strings.TrimSpace(strings.TrimPrefix(arg, "--auth-scheme="))
|
|
case arg == "--auth-header-value":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.authHeaderValue = value
|
|
index = next
|
|
case strings.HasPrefix(arg, "--auth-header-value="):
|
|
value, err := requiredInlineFlagValue(arg, "--auth-header-value")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.authHeaderValue = value
|
|
case arg == "--header":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
if err := addCustomHeader(&options, value); err != nil {
|
|
return options, false, err
|
|
}
|
|
index = next
|
|
case strings.HasPrefix(arg, "--header="):
|
|
value, err := requiredInlineFlagValue(arg, "--header")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
if err := addCustomHeader(&options, value); err != nil {
|
|
return options, false, err
|
|
}
|
|
case strings.HasPrefix(arg, "-"):
|
|
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
|
|
default:
|
|
if options.catalogID != "" {
|
|
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
|
}
|
|
options.catalogID = arg
|
|
}
|
|
}
|
|
if strings.TrimSpace(options.catalogID) == "" {
|
|
return options, false, execUsageError{"provider catalog id is required"}
|
|
}
|
|
return options, false, nil
|
|
}
|
|
|
|
func parseProviderCheckArgs(args []string) (providerCheckOptions, bool, error) {
|
|
options := providerCheckOptions{}
|
|
for index := 0; index < len(args); index++ {
|
|
arg := args[index]
|
|
switch {
|
|
case arg == "-h" || arg == "--help" || arg == "help":
|
|
return options, true, nil
|
|
case arg == "--json":
|
|
options.json = true
|
|
case arg == "--connectivity":
|
|
options.connectivity = true
|
|
case strings.HasPrefix(arg, "-"):
|
|
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
|
|
default:
|
|
if options.name != "" {
|
|
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
|
}
|
|
options.name = arg
|
|
}
|
|
}
|
|
return options, false, nil
|
|
}
|
|
|
|
func addCustomHeader(options *providerAddOptions, value string) error {
|
|
key, headerValue, ok := strings.Cut(value, "=")
|
|
key = strings.TrimSpace(key)
|
|
if !ok || key == "" {
|
|
return execUsageError{fmt.Sprintf("invalid header %q; want key=value", value)}
|
|
}
|
|
if options.customHeaders == nil {
|
|
options.customHeaders = map[string]string{}
|
|
}
|
|
options.customHeaders[key] = strings.TrimSpace(headerValue)
|
|
return nil
|
|
}
|
|
|
|
func providerProfileForAdd(options providerAddOptions) (config.ProviderProfile, error) {
|
|
descriptor, err := providercatalog.Require(options.catalogID)
|
|
if err != nil {
|
|
return config.ProviderProfile{}, err
|
|
}
|
|
if !providercatalog.RuntimeSupported(descriptor) {
|
|
return config.ProviderProfile{}, fmt.Errorf("provider %q uses transport %q: %s", descriptor.ID, descriptor.Transport, providercatalog.RuntimeUnsupportedReason(descriptor))
|
|
}
|
|
name := strings.TrimSpace(options.name)
|
|
if name == "" {
|
|
name = descriptor.ID
|
|
}
|
|
apiKeyEnv := strings.TrimSpace(options.apiKeyEnv)
|
|
if apiKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 {
|
|
apiKeyEnv = descriptor.AuthEnvVars[0]
|
|
}
|
|
baseURL := firstNonEmptyCLI(options.baseURL, descriptor.DefaultBaseURL)
|
|
var catalogHeaders map[string]string
|
|
if sameProviderSetupBaseURL(baseURL, descriptor.DefaultBaseURL) {
|
|
catalogHeaders = descriptor.CustomHeaders
|
|
if strings.EqualFold(strings.TrimSpace(descriptor.ID), "aimlapi") {
|
|
catalogHeaders = aimlapi.WithResolvedPartnerHeader(catalogHeaders)
|
|
}
|
|
}
|
|
// Discovery fallback can pass the nonempty catalog placeholder through
|
|
// either CLI setup path. Never persist it as an Atomic Chat model.
|
|
if descriptor.ID == "atomic-chat-local" {
|
|
model := strings.TrimSpace(options.model)
|
|
if model == "" || model == "local-model" {
|
|
return config.ProviderProfile{}, fmt.Errorf("provider %q serves a locally loaded model; pass --model <id> (run `zero providers detect` to see the served model)", descriptor.ID)
|
|
}
|
|
}
|
|
profile := config.ProviderProfile{
|
|
Name: name,
|
|
ProviderKind: providerKindForDescriptor(descriptor),
|
|
CatalogID: descriptor.ID,
|
|
BaseURL: baseURL,
|
|
APIKeyEnv: apiKeyEnv,
|
|
APIFormat: firstAPIFormat(descriptor),
|
|
AuthHeader: strings.TrimSpace(options.authHeader),
|
|
AuthScheme: normalizeAuthScheme(options.authScheme),
|
|
AuthHeaderValue: strings.TrimSpace(options.authHeaderValue),
|
|
// Catalog attribution belongs only to the catalog endpoint. Explicit
|
|
// user headers still apply to an override/staging/proxy endpoint.
|
|
CustomHeaders: mergeProviderHeaders(catalogHeaders, options.customHeaders),
|
|
Model: firstNonEmptyCLI(options.model, descriptor.DefaultModel),
|
|
}
|
|
return profile, nil
|
|
}
|
|
|
|
func sameProviderSetupBaseURL(left string, right string) bool {
|
|
return strings.EqualFold(
|
|
strings.TrimRight(strings.TrimSpace(left), "/"),
|
|
strings.TrimRight(strings.TrimSpace(right), "/"),
|
|
)
|
|
}
|
|
|
|
func selectProviderForCheck(resolved config.ResolvedConfig, name string) (config.ProviderProfile, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
if config.HasProviderProfile(resolved.Provider) {
|
|
return resolved.Provider, nil
|
|
}
|
|
return config.ProviderProfile{}, fmt.Errorf("no active provider configured")
|
|
}
|
|
for _, profile := range resolved.Providers {
|
|
if profile.Name == name {
|
|
return profile, nil
|
|
}
|
|
}
|
|
return config.ProviderProfile{}, fmt.Errorf("provider %q not found", name)
|
|
}
|
|
|
|
func validateProviderRuntimeReady(profile config.ProviderProfile) error {
|
|
// A stored OAuth login (e.g. `zero auth chatgpt`) is a credential too: a
|
|
// keyless token-login profile must pass the readiness check instead of being
|
|
// told to set an API key it will never have.
|
|
if profile.CatalogID != "" {
|
|
descriptor, err := providercatalog.Require(profile.CatalogID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !providercatalog.RuntimeSupported(descriptor) {
|
|
return fmt.Errorf("provider %q uses transport %q: %s", descriptor.ID, descriptor.Transport, providercatalog.RuntimeUnsupportedReason(descriptor))
|
|
}
|
|
}
|
|
if providerHasOAuthLogin(profile, oauthLoggedInProviders()) {
|
|
return nil
|
|
}
|
|
// Delegates to the single source of truth shared with the TUI and the
|
|
// setup wizard, so `providers check` never disagrees with them about
|
|
// whether a saved provider (including a no-auth custom endpoint) is
|
|
// actually missing a credential.
|
|
if envVar, missing := profile.MissingCredentialEnv(); missing {
|
|
if envVar != "" {
|
|
return fmt.Errorf("provider %s requires API key; set %s", profile.Name, envVar)
|
|
}
|
|
return fmt.Errorf("provider %s requires API key", profile.Name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func providerProfileHasCredential(profile config.ProviderProfile) bool {
|
|
return profile.HasConfiguredCredential()
|
|
}
|
|
|
|
func providerKindForDescriptor(descriptor providercatalog.Descriptor) config.ProviderKind {
|
|
switch descriptor.Transport {
|
|
case providercatalog.TransportOpenAI:
|
|
return config.ProviderKindOpenAI
|
|
case providercatalog.TransportAnthropic:
|
|
return config.ProviderKindAnthropic
|
|
case providercatalog.TransportAnthropicCompatible:
|
|
return config.ProviderKindAnthropicCompat
|
|
case providercatalog.TransportGoogle:
|
|
return config.ProviderKindGoogle
|
|
case providercatalog.TransportOpenAICompatible:
|
|
return config.ProviderKindOpenAICompatible
|
|
default:
|
|
return config.ProviderKind(strings.ToLower(string(descriptor.Transport)))
|
|
}
|
|
}
|
|
|
|
func firstAPIFormat(descriptor providercatalog.Descriptor) string {
|
|
if descriptor.Transport == providercatalog.TransportOpenAI || descriptor.Transport == providercatalog.TransportOpenAICompatible {
|
|
return string(providercatalog.APIFormatOpenAIChatCompletions)
|
|
}
|
|
if len(descriptor.SupportedAPIFormats) == 0 {
|
|
return ""
|
|
}
|
|
return string(descriptor.SupportedAPIFormats[0])
|
|
}
|
|
|
|
func firstNonEmptyCLI(values ...string) string {
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizeAuthScheme(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if strings.EqualFold(value, "none") || strings.EqualFold(value, "raw") {
|
|
return ""
|
|
}
|
|
return value
|
|
}
|
|
|
|
func copyProviderHeaders(headers map[string]string) map[string]string {
|
|
if headers == nil {
|
|
return nil
|
|
}
|
|
copied := make(map[string]string, len(headers))
|
|
for key, value := range headers {
|
|
copied[key] = value
|
|
}
|
|
return copied
|
|
}
|
|
|
|
func mergeProviderHeaders(base map[string]string, overrides map[string]string) map[string]string {
|
|
merged := copyProviderHeaders(base)
|
|
if len(overrides) == 0 {
|
|
return merged
|
|
}
|
|
if merged == nil {
|
|
merged = map[string]string{}
|
|
}
|
|
for key, value := range overrides {
|
|
// HTTP header names are case-insensitive and request construction
|
|
// canonicalizes them (http.Header.Set), so an override that differs from a
|
|
// base key only in case must replace that key's value in place — otherwise
|
|
// both survive as distinct map entries and race non-deterministically at
|
|
// build time. Keep the existing spelling (the catalog's, which the resolver
|
|
// re-merges verbatim); only a genuinely new header is added as typed.
|
|
if existing, ok := headerKeyFold(merged, key); ok {
|
|
merged[existing] = value
|
|
continue
|
|
}
|
|
merged[key] = value
|
|
}
|
|
return merged
|
|
}
|
|
|
|
// headerKeyFold returns the key in headers whose canonical HTTP form matches key,
|
|
// so a case-insensitive override lands on the existing entry instead of adding a
|
|
// colliding duplicate.
|
|
func headerKeyFold(headers map[string]string, key string) (string, bool) {
|
|
if _, ok := headers[key]; ok {
|
|
return key, true
|
|
}
|
|
canonical := http.CanonicalHeaderKey(key)
|
|
for existing := range headers {
|
|
if http.CanonicalHeaderKey(existing) == canonical {
|
|
return existing, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|