diff options
| author | paul@buetow.org <paul@buetow.org> | 2026-02-06 16:35:45 +0200 |
|---|---|---|
| committer | paul@buetow.org <paul@buetow.org> | 2026-02-06 16:35:45 +0200 |
| commit | 12a249282d5dd9dc2ee1e66f08d6acc26dd29eba (patch) | |
| tree | 5e9ae4fbd1696d1b668dfe0be791004a87fc7a6a /internal | |
| parent | 89dc2aab0b6be2620766a4b4b750fa888641b89d (diff) | |
Remove GitHub Copilot provider support
Remove all GitHub Copilot integration from the codebase to streamline
the supported provider set to OpenAI, OpenRouter, Anthropic, and Ollama.
Changes:
- Delete core Copilot implementation (copilot.go) and all related tests
- Remove Copilot configuration fields from App struct and Config
- Remove Copilot from provider factory and API key handling
- Update all test files to replace Copilot references with other providers
- Remove Copilot documentation from README, configuration guide, and examples
- Remove Copilot section from config.toml.example
All tests pass successfully after removal.
Co-authored-by: Cursor <cursoragent@cursor.com>
Diffstat (limited to 'internal')
23 files changed, 52 insertions, 909 deletions
diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index c9af85e..3077d42 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -69,12 +69,8 @@ type App struct { OllamaModel string `json:"ollama_model" toml:"ollama_model"` // Default temperature for Ollama requests (nil means use provider default) OllamaTemperature *float64 `json:"ollama_temperature" toml:"ollama_temperature"` - CopilotBaseURL string `json:"copilot_base_url" toml:"copilot_base_url"` - CopilotModel string `json:"copilot_model" toml:"copilot_model"` - // Default temperature for Copilot requests (nil means use provider default) - CopilotTemperature *float64 `json:"copilot_temperature" toml:"copilot_temperature"` - AnthropicBaseURL string `json:"anthropic_base_url" toml:"anthropic_base_url"` - AnthropicModel string `json:"anthropic_model" toml:"anthropic_model"` + AnthropicBaseURL string `json:"anthropic_base_url" toml:"anthropic_base_url"` + AnthropicModel string `json:"anthropic_model" toml:"anthropic_model"` // Default temperature for Anthropic requests (nil means use provider default) AnthropicTemperature *float64 `json:"anthropic_temperature" toml:"anthropic_temperature"` @@ -146,7 +142,6 @@ func newDefaultConfig() App { CodingTemperature: &t, OpenAITemperature: &t, OllamaTemperature: &t, - CopilotTemperature: &t, AnthropicTemperature: &t, ManualInvokeMinPrefix: 0, CompletionDebounceMs: 800, @@ -244,7 +239,6 @@ type fileConfig struct { Provider sectionProvider `toml:"provider"` OpenAI sectionOpenAI `toml:"openai"` OpenRouter sectionOpenRouter `toml:"openrouter"` - Copilot sectionCopilot `toml:"copilot"` Ollama sectionOllama `toml:"ollama"` Anthropic sectionAnthropic `toml:"anthropic"` Prompts sectionPrompts `toml:"prompts"` @@ -333,12 +327,6 @@ type sectionOpenRouter struct { Temperature *float64 `toml:"temperature"` } -type sectionCopilot struct { - Model string `toml:"model"` - BaseURL string `toml:"base_url"` - Temperature *float64 `toml:"temperature"` -} - type sectionOllama struct { Model string `toml:"model"` BaseURL string `toml:"base_url"` @@ -489,16 +477,6 @@ func (fc *fileConfig) toApp() App { out.mergeProviderFields(&tmp) } - // copilot - if (fc.Copilot != sectionCopilot{}) || fc.Copilot.Temperature != nil { - tmp := App{ - CopilotBaseURL: fc.Copilot.BaseURL, - CopilotModel: fc.Copilot.Model, - CopilotTemperature: fc.Copilot.Temperature, - } - out.mergeProviderFields(&tmp) - } - // ollama if (fc.Ollama != sectionOllama{}) || fc.Ollama.Temperature != nil { tmp := App{ @@ -658,10 +636,9 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) { "chat_suffix": {}, "chat_prefixes": {}, "coding_temperature": {}, "provider": {}, "openai_model": {}, "openai_base_url": {}, "openai_temperature": {}, "ollama_model": {}, "ollama_base_url": {}, "ollama_temperature": {}, - "copilot_model": {}, "copilot_base_url": {}, "copilot_temperature": {}, } for k := range raw { - if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "models": {}, "openai": {}, "copilot": {}, "ollama": {}, "prompts": {}}[k]; isTable { + if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "models": {}, "openai": {}, "ollama": {}, "prompts": {}}[k]; isTable { continue } if _, isLegacy := legacy[k]; isLegacy { @@ -1103,15 +1080,6 @@ func (a *App) mergeProviderFields(other *App) { if other.OllamaTemperature != nil { // allow explicit 0.0 a.OllamaTemperature = other.OllamaTemperature } - if s := strings.TrimSpace(other.CopilotBaseURL); s != "" { - a.CopilotBaseURL = s - } - if s := strings.TrimSpace(other.CopilotModel); s != "" { - a.CopilotModel = s - } - if other.CopilotTemperature != nil { // allow explicit 0.0 - a.CopilotTemperature = other.CopilotTemperature - } if s := strings.TrimSpace(other.AnthropicBaseURL); s != "" { a.AnthropicBaseURL = s } @@ -1331,19 +1299,6 @@ func loadFromEnv(logger *log.Logger) *App { any = true } - if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" { - out.CopilotBaseURL = s - any = true - } - if model, ok := pickModel("copilot", getenv("HEXAI_COPILOT_MODEL")); ok { - out.CopilotModel = model - any = true - } - if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok { - out.CopilotTemperature = f - any = true - } - if s := getenv("HEXAI_ANTHROPIC_BASE_URL"); s != "" { out.AnthropicBaseURL = s any = true diff --git a/internal/appconfig/config_env_model_test.go b/internal/appconfig/config_env_model_test.go index 7038819..e10fa5d 100644 --- a/internal/appconfig/config_env_model_test.go +++ b/internal/appconfig/config_env_model_test.go @@ -37,9 +37,9 @@ func TestEnv_ModelForce_OverridesProviderSpecific(t *testing.T) { } func TestEnv_SurfaceModelOverrides(t *testing.T) { - t.Setenv("HEXAI_MODEL_COMPLETION", "gpt-c") + t.Setenv("HEXAI_MODEL_COMPLETION", "claude-c") t.Setenv("HEXAI_TEMPERATURE_COMPLETION", "0.44") - t.Setenv("HEXAI_PROVIDER_COMPLETION", "copilot") + t.Setenv("HEXAI_PROVIDER_COMPLETION", "anthropic") t.Setenv("HEXAI_MODEL_CLI", "gpt-cli") t.Setenv("HEXAI_TEMPERATURE_CLI", "0.22") t.Setenv("HEXAI_PROVIDER_CLI", "ollama") @@ -48,13 +48,13 @@ func TestEnv_SurfaceModelOverrides(t *testing.T) { t.Fatalf("expected single completion entry, got %+v", cfg.CompletionConfigs) } comp := cfg.CompletionConfigs[0] - if comp.Model != "gpt-c" { + if comp.Model != "claude-c" { t.Fatalf("expected completion model override, got %+v", comp) } if comp.Temperature == nil || *comp.Temperature != 0.44 { t.Fatalf("expected completion temperature override, got %+v", comp) } - if comp.Provider != "copilot" { + if comp.Provider != "anthropic" { t.Fatalf("expected completion provider override, got %+v", comp) } if len(cfg.CLIConfigs) != 1 { diff --git a/internal/appconfig/config_test.go b/internal/appconfig/config_test.go index 2c00f68..ff9616b 100644 --- a/internal/appconfig/config_test.go +++ b/internal/appconfig/config_test.go @@ -75,8 +75,8 @@ func TestParseSurfaceModels_CodeActionWarns(t *testing.T) { model = "gpt-4o" [[models.code_action]] - provider = "copilot" - model = "cpt" + provider = "anthropic" + model = "claude" `) var buf bytes.Buffer logger := log.New(&buf, "", 0) @@ -121,9 +121,9 @@ model = "gpt-file-complete" provider = "openai" [[models.code_action]] -model = "gpt-file-action" +model = "claude-file-action" temperature = 0.45 -provider = "copilot" +provider = "anthropic" [[models.chat]] model = "gpt-file-chat" @@ -146,11 +146,6 @@ temperature = 0.0 base_url = "http://ollama" model = "llama" temperature = 0.0 - -[copilot] -base_url = "http://copilot" -model = "ghost" -temperature = 0.0 `) if _, err := loadFromFile(cfgPath, newLogger()); err != nil { @@ -175,18 +170,15 @@ temperature = 0.0 withEnv(t, "HEXAI_OLLAMA_BASE_URL", "http://ollama-override") withEnv(t, "HEXAI_OLLAMA_MODEL", "mistral") withEnv(t, "HEXAI_OLLAMA_TEMPERATURE", "0.6") - withEnv(t, "HEXAI_COPILOT_BASE_URL", "http://copilot-override") - withEnv(t, "HEXAI_COPILOT_MODEL", "ghost-override") - withEnv(t, "HEXAI_COPILOT_TEMPERATURE", "0.3") withEnv(t, "HEXAI_MODEL_COMPLETION", "env-completion") withEnv(t, "HEXAI_TEMPERATURE_COMPLETION", "0.33") - withEnv(t, "HEXAI_PROVIDER_COMPLETION", "copilot") + withEnv(t, "HEXAI_PROVIDER_COMPLETION", "ollama") withEnv(t, "HEXAI_MODEL_CODE_ACTION", "env-action") withEnv(t, "HEXAI_TEMPERATURE_CODE_ACTION", "0.55") withEnv(t, "HEXAI_PROVIDER_CODE_ACTION", "openai") withEnv(t, "HEXAI_MODEL_CHAT", "env-chat") withEnv(t, "HEXAI_TEMPERATURE_CHAT", "0.66") - withEnv(t, "HEXAI_PROVIDER_CHAT", "copilot") + withEnv(t, "HEXAI_PROVIDER_CHAT", "anthropic") withEnv(t, "HEXAI_MODEL_CLI", "env-cli") withEnv(t, "HEXAI_TEMPERATURE_CLI", "0.77") withEnv(t, "HEXAI_PROVIDER_CLI", "ollama") @@ -217,16 +209,13 @@ temperature = 0.0 if cfg.OllamaBaseURL != "http://ollama-override" || cfg.OllamaModel != "mistral" || cfg.OllamaTemperature == nil || *cfg.OllamaTemperature != 0.6 { t.Fatalf("ollama overrides not applied: %+v", cfg) } - if cfg.CopilotBaseURL != "http://copilot-override" || cfg.CopilotModel != "ghost-override" || cfg.CopilotTemperature == nil || *cfg.CopilotTemperature != 0.3 { - t.Fatalf("copilot overrides not applied: %+v", cfg) - } if len(cfg.CompletionConfigs) != 1 || cfg.CompletionConfigs[0].Model != "env-completion" { t.Fatalf("completion overrides not applied: %+v", cfg.CompletionConfigs) } if cfg.CompletionConfigs[0].Temperature == nil || *cfg.CompletionConfigs[0].Temperature != 0.33 { t.Fatalf("completion temperature override missing: %+v", cfg.CompletionConfigs[0]) } - if cfg.CompletionConfigs[0].Provider != "copilot" { + if cfg.CompletionConfigs[0].Provider != "ollama" { t.Fatalf("completion provider override not applied: %+v", cfg.CompletionConfigs[0]) } if len(cfg.CodeActionConfigs) != 1 || cfg.CodeActionConfigs[0].Model != "env-action" { @@ -244,7 +233,7 @@ temperature = 0.0 if cfg.ChatConfigs[0].Temperature == nil || *cfg.ChatConfigs[0].Temperature != 0.66 { t.Fatalf("chat temp override missing: %+v", cfg.ChatConfigs[0]) } - if cfg.ChatConfigs[0].Provider != "copilot" { + if cfg.ChatConfigs[0].Provider != "anthropic" { t.Fatalf("chat provider override not applied: %+v", cfg.ChatConfigs[0]) } if len(cfg.CLIConfigs) != 1 || cfg.CLIConfigs[0].Model != "env-cli" { @@ -260,7 +249,7 @@ temperature = 0.0 // Ensure file values would have applied absent env // Spot-check: reset env and reload for _, k := range []string{ - "HEXAI_MAX_TOKENS", "HEXAI_CONTEXT_MODE", "HEXAI_CONTEXT_WINDOW_LINES", "HEXAI_MAX_CONTEXT_TOKENS", "HEXAI_LOG_PREVIEW_LIMIT", "HEXAI_CODING_TEMPERATURE", "HEXAI_MANUAL_INVOKE_MIN_PREFIX", "HEXAI_COMPLETION_DEBOUNCE_MS", "HEXAI_COMPLETION_THROTTLE_MS", "HEXAI_TRIGGER_CHARACTERS", "HEXAI_PROVIDER", "HEXAI_OPENAI_BASE_URL", "HEXAI_OPENAI_MODEL", "HEXAI_OPENAI_TEMPERATURE", "HEXAI_OLLAMA_BASE_URL", "HEXAI_OLLAMA_MODEL", "HEXAI_OLLAMA_TEMPERATURE", "HEXAI_COPILOT_BASE_URL", "HEXAI_COPILOT_MODEL", "HEXAI_COPILOT_TEMPERATURE", "HEXAI_MODEL_COMPLETION", "HEXAI_TEMPERATURE_COMPLETION", "HEXAI_MODEL_CODE_ACTION", "HEXAI_TEMPERATURE_CODE_ACTION", "HEXAI_MODEL_CHAT", "HEXAI_TEMPERATURE_CHAT", "HEXAI_MODEL_CLI", "HEXAI_TEMPERATURE_CLI", "HEXAI_PROVIDER_COMPLETION", "HEXAI_PROVIDER_CODE_ACTION", "HEXAI_PROVIDER_CHAT", "HEXAI_PROVIDER_CLI", + "HEXAI_MAX_TOKENS", "HEXAI_CONTEXT_MODE", "HEXAI_CONTEXT_WINDOW_LINES", "HEXAI_MAX_CONTEXT_TOKENS", "HEXAI_LOG_PREVIEW_LIMIT", "HEXAI_CODING_TEMPERATURE", "HEXAI_MANUAL_INVOKE_MIN_PREFIX", "HEXAI_COMPLETION_DEBOUNCE_MS", "HEXAI_COMPLETION_THROTTLE_MS", "HEXAI_TRIGGER_CHARACTERS", "HEXAI_PROVIDER", "HEXAI_OPENAI_BASE_URL", "HEXAI_OPENAI_MODEL", "HEXAI_OPENAI_TEMPERATURE", "HEXAI_OLLAMA_BASE_URL", "HEXAI_OLLAMA_MODEL", "HEXAI_OLLAMA_TEMPERATURE", "HEXAI_MODEL_COMPLETION", "HEXAI_TEMPERATURE_COMPLETION", "HEXAI_MODEL_CODE_ACTION", "HEXAI_TEMPERATURE_CODE_ACTION", "HEXAI_MODEL_CHAT", "HEXAI_TEMPERATURE_CHAT", "HEXAI_MODEL_CLI", "HEXAI_TEMPERATURE_CLI", "HEXAI_PROVIDER_COMPLETION", "HEXAI_PROVIDER_CODE_ACTION", "HEXAI_PROVIDER_CHAT", "HEXAI_PROVIDER_CLI", } { t.Setenv(k, "") } @@ -283,13 +272,13 @@ temperature = 0.0 if cfg2.CompletionConfigs[0].Provider != "openai" { t.Fatalf("file merge (completion provider) not applied: %+v", cfg2.CompletionConfigs[0]) } - if len(cfg2.CodeActionConfigs) != 1 || cfg2.CodeActionConfigs[0].Model != "gpt-file-action" { + if len(cfg2.CodeActionConfigs) != 1 || cfg2.CodeActionConfigs[0].Model != "claude-file-action" { t.Fatalf("file merge (code action) not applied: %+v", cfg2.CodeActionConfigs) } if cfg2.CodeActionConfigs[0].Temperature == nil || *cfg2.CodeActionConfigs[0].Temperature != 0.45 { t.Fatalf("expected code action temp 0.45, got %+v", cfg2.CodeActionConfigs[0]) } - if cfg2.CodeActionConfigs[0].Provider != "copilot" { + if cfg2.CodeActionConfigs[0].Provider != "anthropic" { t.Fatalf("file merge (code action provider) not applied: %+v", cfg2.CodeActionConfigs[0]) } if len(cfg2.ChatConfigs) != 1 || cfg2.ChatConfigs[0].Model != "gpt-file-chat" { @@ -384,11 +373,6 @@ temperature = 0.0 model = "mistral" base_url = "http://ollama" temperature = 0.0 - -[copilot] -model = "ghost" -base_url = "http://copilot" -temperature = 0.0 ` writeFile(t, cfgPath, content) @@ -418,9 +402,6 @@ temperature = 0.0 if cfg.OllamaModel != "mistral" || cfg.OllamaBaseURL != "http://ollama" || cfg.OllamaTemperature == nil || *cfg.OllamaTemperature != 0.0 { t.Fatalf("sectioned ollama wrong: %+v", cfg) } - if cfg.CopilotModel != "ghost" || cfg.CopilotBaseURL != "http://copilot" || cfg.CopilotTemperature == nil || *cfg.CopilotTemperature != 0.0 { - t.Fatalf("sectioned copilot wrong: %+v", cfg) - } } func TestLoad_FileTables_Prompts_AllSections(t *testing.T) { diff --git a/internal/hexaiaction/prompts.go b/internal/hexaiaction/prompts.go index a113391..fc743a0 100644 --- a/internal/hexaiaction/prompts.go +++ b/internal/hexaiaction/prompts.go @@ -49,8 +49,8 @@ func defaultModelForProvider(cfg appconfig.App, provider string) string { switch provider { case "ollama": return cfg.OllamaModel - case "copilot": - return cfg.CopilotModel + case "anthropic": + return cfg.AnthropicModel default: return cfg.OpenAIModel } diff --git a/internal/hexaiaction/prompts_more_test.go b/internal/hexaiaction/prompts_more_test.go index cfccd0c..a4410e5 100644 --- a/internal/hexaiaction/prompts_more_test.go +++ b/internal/hexaiaction/prompts_more_test.go @@ -35,8 +35,8 @@ func TestReqOptsFrom_Override(t *testing.T) { cfg := appconfig.App{ MaxTokens: 123, Provider: "openai", - CopilotModel: "gpt-4o", - CodeActionConfigs: []appconfig.SurfaceConfig{{Provider: "copilot", Model: "override", Temperature: ptrFloat(0.6)}}, + AnthropicModel: "claude-3-5-sonnet", + CodeActionConfigs: []appconfig.SurfaceConfig{{Provider: "anthropic", Model: "override", Temperature: ptrFloat(0.6)}}, } req := reqOptsFrom(cfg) if req.model != "override" { diff --git a/internal/hexaiaction/run_more_test.go b/internal/hexaiaction/run_more_test.go index a3e7f25..57bd933 100644 --- a/internal/hexaiaction/run_more_test.go +++ b/internal/hexaiaction/run_more_test.go @@ -14,7 +14,7 @@ import ( // Covers the early error path in Run when no API key is available for the default provider. func TestRun_MissingAPIKey(t *testing.T) { // Ensure no provider API keys in env - for _, k := range []string{"HEXAI_OPENAI_API_KEY", "OPENAI_API_KEY", "HEXAI_COPILOT_API_KEY", "COPILOT_API_KEY"} { + for _, k := range []string{"HEXAI_OPENAI_API_KEY", "OPENAI_API_KEY"} { t.Setenv(k, "") } // Provide minimal stdin to get past empty input check (if reached) diff --git a/internal/hexaicli/run.go b/internal/hexaicli/run.go index 7b360e9..9ea3a40 100644 --- a/internal/hexaicli/run.go +++ b/internal/hexaicli/run.go @@ -77,10 +77,6 @@ func buildCLIJobs(cfg appconfig.App) ([]cliJob, error) { if entry.Model != "" { derived.OpenAIModel = entry.Model } - case "copilot": - if entry.Model != "" { - derived.CopilotModel = entry.Model - } case "ollama": if entry.Model != "" { derived.OllamaModel = entry.Model @@ -151,8 +147,8 @@ func defaultModelForProvider(cfg appconfig.App, provider string) string { switch provider { case "ollama": return cfg.OllamaModel - case "copilot": - return cfg.CopilotModel + case "anthropic": + return cfg.AnthropicModel default: return cfg.OpenAIModel } diff --git a/internal/hexaicli/run_test.go b/internal/hexaicli/run_test.go index 43576cf..34a5c51 100644 --- a/internal/hexaicli/run_test.go +++ b/internal/hexaicli/run_test.go @@ -161,11 +161,11 @@ func TestPrintProviderInfo(t *testing.T) { func TestBuildCLIRequest_Override(t *testing.T) { cfg := appconfig.App{ - Provider: "openai", - CopilotModel: "gpt-4o", + Provider: "openai", + AnthropicModel: "claude-3-5-sonnet", } - entry := appconfig.SurfaceConfig{Provider: "copilot", Model: "override", Temperature: floatPtr(0.7)} - req := buildCLIRequest(entry, "copilot", cfg, &fakeClient{name: "copilot", model: "default"}) + entry := appconfig.SurfaceConfig{Provider: "anthropic", Model: "override", Temperature: floatPtr(0.7)} + req := buildCLIRequest(entry, "anthropic", cfg, &fakeClient{name: "anthropic", model: "default"}) if req.model != "override" { t.Fatalf("expected model override, got %q", req.model) } @@ -199,8 +199,8 @@ func TestBuildCLIJobs_MultiEntries(t *testing.T) { defer func() { newClientFromApp = old }() newClientFromApp = func(cfg appconfig.App) (llm.Client, error) { model := cfg.OpenAIModel - if cfg.Provider == "copilot" { - model = cfg.CopilotModel + if cfg.Provider == "anthropic" { + model = cfg.AnthropicModel } if cfg.Provider == "ollama" { model = cfg.OllamaModel @@ -215,7 +215,7 @@ func TestBuildCLIJobs_MultiEntries(t *testing.T) { OllamaModel: "llama3", CLIConfigs: []appconfig.SurfaceConfig{ {Provider: "openai", Model: "gpt-4o"}, - {Provider: "copilot", Model: "cpt"}, + {Provider: "anthropic", Model: "claude"}, }, } jobs, err := buildCLIJobs(cfg) @@ -228,18 +228,18 @@ func TestBuildCLIJobs_MultiEntries(t *testing.T) { if jobs[0].provider != "openai" || jobs[0].req.model != "gpt-4o" { t.Fatalf("unexpected first job: %+v", jobs[0]) } - if jobs[1].provider != "copilot" || jobs[1].req.model != "cpt" { + if jobs[1].provider != "anthropic" || jobs[1].req.model != "claude" { t.Fatalf("unexpected second job: %+v", jobs[1]) } } func TestFilterJobsBySelection(t *testing.T) { - jobs := []cliJob{{index: 0, provider: "openai"}, {index: 1, provider: "ollama"}, {index: 2, provider: "copilot"}} + jobs := []cliJob{{index: 0, provider: "openai"}, {index: 1, provider: "ollama"}, {index: 2, provider: "anthropic"}} filtered, err := filterJobsBySelection(jobs, []int{2, 0}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(filtered) != 2 || filtered[0].provider != "copilot" || filtered[1].provider != "openai" { + if len(filtered) != 2 || filtered[0].provider != "anthropic" || filtered[1].provider != "openai" { t.Fatalf("unexpected filtered order: %+v", filtered) } if filtered[0].index != 0 || filtered[1].index != 1 { diff --git a/internal/hexaicli/testhelpers_test.go b/internal/hexaicli/testhelpers_test.go index 8f6863d..3197880 100644 --- a/internal/hexaicli/testhelpers_test.go +++ b/internal/hexaicli/testhelpers_test.go @@ -63,7 +63,6 @@ func (s *fakeStreamer) ChatStream(ctx context.Context, messages []llm.Message, o return nil } - func writeConfigString(t *testing.T, path string, contents string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { diff --git a/internal/hexailsp/run.go b/internal/hexailsp/run.go index 47ed648..e2aaf9d 100644 --- a/internal/hexailsp/run.go +++ b/internal/hexailsp/run.go @@ -119,9 +119,6 @@ func buildClientIfNil(cfg appconfig.App, client llm.Client) llm.Client { OllamaBaseURL: cfg.OllamaBaseURL, OllamaModel: cfg.OllamaModel, OllamaTemperature: cfg.OllamaTemperature, - CopilotBaseURL: cfg.CopilotBaseURL, - CopilotModel: cfg.CopilotModel, - CopilotTemperature: cfg.CopilotTemperature, AnthropicBaseURL: cfg.AnthropicBaseURL, AnthropicModel: cfg.AnthropicModel, AnthropicTemperature: cfg.AnthropicTemperature, @@ -136,17 +133,12 @@ func buildClientIfNil(cfg appconfig.App, client llm.Client) llm.Client { if strings.TrimSpace(orKey) == "" { orKey = os.Getenv("OPENROUTER_API_KEY") } - // Prefer HEXAI_COPILOT_API_KEY; fall back to COPILOT_API_KEY - cpKey := os.Getenv("HEXAI_COPILOT_API_KEY") - if strings.TrimSpace(cpKey) == "" { - cpKey = os.Getenv("COPILOT_API_KEY") - } // Prefer HEXAI_ANTHROPIC_API_KEY; fall back to ANTHROPIC_API_KEY anKey := os.Getenv("HEXAI_ANTHROPIC_API_KEY") if strings.TrimSpace(anKey) == "" { anKey = os.Getenv("ANTHROPIC_API_KEY") } - if c, err := llm.NewFromConfig(llmCfg, oaKey, orKey, cpKey, anKey); err != nil { + if c, err := llm.NewFromConfig(llmCfg, oaKey, orKey, anKey); err != nil { logging.Logf("lsp ", "llm disabled: %v", err) return nil } else { diff --git a/internal/llm/copilot.go b/internal/llm/copilot.go deleted file mode 100644 index 43419ea..0000000 --- a/internal/llm/copilot.go +++ /dev/null @@ -1,412 +0,0 @@ -// Summary: GitHub Copilot client for chat and Codex-style code completion. -package llm - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "regexp" - "strings" - "time" - - appver "codeberg.org/snonux/hexai/internal" - "codeberg.org/snonux/hexai/internal/logging" -) - -// copilotClient implements Client against GitHub Copilot's Chat Completions API. -type copilotClient struct { - httpClient *http.Client - apiKey string - baseURL string - defaultModel string - chatLogger logging.ChatLogger - defaultTemperature *float64 - - // cached Copilot session token retrieved from GitHub API using apiKey - sessionToken string - tokenExpiry time.Time -} - -type copilotChatRequest struct { - Model string `json:"model"` - Messages []copilotMessage `json:"messages"` - Temperature *float64 `json:"temperature,omitempty"` - MaxTokens *int `json:"max_tokens,omitempty"` - Stop []string `json:"stop,omitempty"` -} - -type copilotMessage struct { - Role string `json:"role"` - Content string `json:"content"` -} - -type copilotChatResponse struct { - Choices []struct { - Index int `json:"index"` - Message struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"message"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` - Error *struct { - Message string `json:"message"` - Type string `json:"type"` - Param any `json:"param"` - Code any `json:"code"` - } `json:"error,omitempty"` -} - -// Constructor (kept among the first functions by convention) -func newCopilot(baseURL, model, apiKey string, defaultTemp *float64) Client { - return newCopilotWithTimeout(baseURL, model, apiKey, defaultTemp, 0) -} - -func newCopilotWithTimeout(baseURL, model, apiKey string, defaultTemp *float64, timeoutSec int) Client { - if strings.TrimSpace(baseURL) == "" { - baseURL = "https://api.githubcopilot.com" - } - if strings.TrimSpace(model) == "" { - // GitHub Models (Copilot API) commonly supports gpt-4o/gpt-4o-mini. - // Default to a broadly available, cost-effective option. - model = "gpt-4o-mini" - } - if timeoutSec <= 0 { - timeoutSec = 30 - } - return copilotClient{ - httpClient: &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}, - apiKey: apiKey, - baseURL: strings.TrimRight(baseURL, "/"), - defaultModel: model, - chatLogger: logging.NewChatLogger("copilot"), - defaultTemperature: defaultTemp, - } -} - -func (c copilotClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) { - if strings.TrimSpace(c.apiKey) == "" { - return nilStringErr("missing Copilot API key") - } - // Ensure we have a fresh session token - if err := c.ensureSession(ctx); err != nil { - return "", err - } - o := Options{Model: c.defaultModel} - for _, opt := range opts { - opt(&o) - } - if o.Model == "" { - o.Model = c.defaultModel - } - start := time.Now() - logMessages := make([]struct{ Role, Content string }, len(messages)) - for i, m := range messages { - logMessages[i] = struct{ Role, Content string }{m.Role, m.Content} - } - c.chatLogger.LogStart(false, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages) - - req := buildCopilotChatRequest(o, messages, c.defaultTemperature) - body, err := json.Marshal(req) - if err != nil { - logging.Logf("llm/copilot ", "marshal error: %v", err) - return "", err - } - - endpoint := c.baseURL + "/chat/completions" - logging.Logf("llm/copilot ", "POST %s", endpoint) - resp, err := c.postJSON(ctx, endpoint, body, c.headersChat()) - if err != nil { - logging.Logf("llm/copilot ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) - return "", err - } - defer func() { - if err := resp.Body.Close(); err != nil { - logging.Logf("llm/copilot", "failed to close response body: %v", err) - } - }() - if err := handleCopilotNon2xx(resp, start); err != nil { - return "", err - } - out, err := decodeCopilotChat(resp, start) - if err != nil { - return "", err - } - if len(out.Choices) == 0 { - logging.Logf("llm/copilot ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase) - return "", errors.New("copilot: no choices returned") - } - content := out.Choices[0].Message.Content - logging.Logf("llm/copilot ", "success choice=0 finish=%s size=%d preview=%s%s%s duration=%s", out.Choices[0].FinishReason, len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start)) - return content, nil -} - -// Provider metadata -func (c copilotClient) Name() string { return "copilot" } -func (c copilotClient) DefaultModel() string { return c.defaultModel } - -// helpers -func buildCopilotChatRequest(o Options, messages []Message, defaultTemp *float64) copilotChatRequest { - req := copilotChatRequest{Model: o.Model} - req.Messages = make([]copilotMessage, len(messages)) - for i, m := range messages { - req.Messages[i] = copilotMessage(m) - } - if o.Temperature != 0 { - req.Temperature = &o.Temperature - } else if defaultTemp != nil { - t := *defaultTemp - req.Temperature = &t - } - if o.MaxTokens > 0 { - req.MaxTokens = &o.MaxTokens - } - if len(o.Stop) > 0 { - req.Stop = o.Stop - } - return req -} - -func (c copilotClient) postJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return nil, err - } - for k, v := range headers { - req.Header.Set(k, v) - } - return c.httpClient.Do(req) -} - -func handleCopilotNon2xx(resp *http.Response, start time.Time) error { - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - return nil - } - var apiErr copilotChatResponse - _ = json.NewDecoder(resp.Body).Decode(&apiErr) - if apiErr.Error != nil && strings.TrimSpace(apiErr.Error.Message) != "" { - logging.Logf("llm/copilot ", "%sapi error status=%d type=%s msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error.Type, apiErr.Error.Message, time.Since(start), logging.AnsiBase) - return fmt.Errorf("copilot error: %s (status %d)", apiErr.Error.Message, resp.StatusCode) - } - logging.Logf("llm/copilot ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) - return fmt.Errorf("copilot http error: status %d", resp.StatusCode) -} - -func decodeCopilotChat(resp *http.Response, start time.Time) (copilotChatResponse, error) { - var out copilotChatResponse - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - logging.Logf("llm/copilot ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) - return copilotChatResponse{}, err - } - return out, nil -} - -// --- Copilot session token management --- - -type ghCopilotTokenResp struct { - Token string `json:"token"` -} - -func (c *copilotClient) ensureSession(ctx context.Context) error { - // If token valid for >60s, reuse - if c.sessionToken != "" && time.Now().Add(60*time.Second).Before(c.tokenExpiry) { - return nil - } - if strings.TrimSpace(c.apiKey) == "" { - return errors.New("missing Copilot API key") - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/copilot_internal/v2/token", nil) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+c.apiKey) - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", "hexai/"+appver.Version) - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - defer func() { - if err := resp.Body.Close(); err != nil { - logging.Logf("llm/copilot", "failed to close response body: %v", err) - } - }() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("copilot token http error: %d", resp.StatusCode) - } - var out ghCopilotTokenResp - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return err - } - if strings.TrimSpace(out.Token) == "" { - return errors.New("empty copilot session token") - } - // Parse JWT exp - exp := parseJWTExp(out.Token) - if exp.IsZero() { - exp = time.Now().Add(10 * time.Minute) - } - c.sessionToken = out.Token - c.tokenExpiry = exp - return nil -} - -var jwtExpRe = regexp.MustCompile(`"exp"\s*:\s*([0-9]+)`) // fallback if we can't base64 decode - -func parseJWTExp(token string) time.Time { - parts := strings.Split(token, ".") - if len(parts) < 2 { - return time.Time{} - } - b, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - if m := jwtExpRe.FindStringSubmatch(token); len(m) == 2 { - if n, err2 := parseInt64(m[1]); err2 == nil { - return time.Unix(n, 0) - } - } - return time.Time{} - } - var payload struct { - Exp int64 `json:"exp"` - } - _ = json.Unmarshal(b, &payload) - if payload.Exp == 0 { - return time.Time{} - } - return time.Unix(payload.Exp, 0) -} - -func parseInt64(s string) (int64, error) { var n int64; _, err := fmt.Sscan(s, &n); return n, err } - -// --- Copilot headers --- - -func (c *copilotClient) headersChat() map[string]string { - _ = c.ensureSession(context.Background()) - h := map[string]string{ - "Content-Type": "application/json; charset=utf-8", - "Accept": "application/json", - "Authorization": "Bearer " + c.sessionToken, - "User-Agent": "GitHubCopilotChat/0.8.0", - "Editor-Plugin-Version": "copilot-chat/0.8.0", - "Editor-Version": "vscode/1.85.1", - "Openai-Intent": "conversation-panel", - "Openai-Organization": "github-copilot", - "VScode-MachineId": randHex(64), - "VScode-SessionId": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), - "X-Request-Id": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), - } - return h -} - -func (c *copilotClient) headersGhost() map[string]string { - _ = c.ensureSession(context.Background()) - h := map[string]string{ - "Content-Type": "application/json; charset=utf-8", - "Accept": "*/*", - "Authorization": "Bearer " + c.sessionToken, - "User-Agent": "GithubCopilot/1.155.0", - "Editor-Plugin-Version": "copilot/1.155.0", - "Editor-Version": "vscode/1.85.1", - "Openai-Intent": "copilot-ghost", - "Openai-Organization": "github-copilot", - "VScode-MachineId": randHex(64), - "VScode-SessionId": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), - "X-Request-Id": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), - } - return h -} - -func randHex(n int) string { - const hex = "0123456789abcdef" - b := make([]byte, n) - for i := range b { - b[i] = hex[int(time.Now().UnixNano()+int64(i))%len(hex)] - } - return string(b) -} - -// --- Codex-style code completion --- - -// CodeCompletion implements CodeCompleter; returns up to n suggestions. -func (c copilotClient) CodeCompletion(ctx context.Context, prompt string, suffix string, n int, language string, temperature float64) ([]string, error) { - if strings.TrimSpace(c.apiKey) == "" { - return nil, errors.New("missing Copilot API key") - } - if err := c.ensureSession(ctx); err != nil { - return nil, err - } - if n <= 0 { - n = 1 - } - maxTokens := 500 - body := map[string]any{ - "extra": map[string]any{ - "language": language, - "next_indent": 0, - "prompt_tokens": 500, - "suffix_tokens": 400, - "trim_by_indentation": true, - }, - "max_tokens": maxTokens, - "n": n, - "nwo": "hexai", - "prompt": prompt, - "stop": []string{"\n\n"}, - "stream": true, - "suffix": suffix, - "temperature": temperature, - "top_p": 1, - } - buf, _ := json.Marshal(body) - url := "https://copilot-proxy.githubusercontent.com/v1/engines/copilot-codex/completions" - resp, err := c.postJSON(ctx, url, buf, c.headersGhost()) - if err != nil { - return nil, err - } - defer func() { - if err := resp.Body.Close(); err != nil { - logging.Logf("llm/copilot", "failed to close response body: %v", err) - } - }() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("copilot codex http error: %d", resp.StatusCode) - } - // Read all and parse lines that start with "data: " accumulating by index - raw, _ := io.ReadAll(resp.Body) - byIndex := make(map[int]string) - lines := strings.Split(string(raw), "\n") - for _, ln := range lines { - if !strings.HasPrefix(ln, "data: ") { - continue - } - var evt struct { - Choices []struct { - Index int `json:"index"` - Text string `json:"text"` - } `json:"choices"` - } - if err := json.Unmarshal([]byte(strings.TrimPrefix(ln, "data: ")), &evt); err != nil { - continue - } - for _, ch := range evt.Choices { - byIndex[ch.Index] += ch.Text - } - } - out := make([]string, 0, len(byIndex)) - for i := 0; i < n; i++ { - if s, ok := byIndex[i]; ok && strings.TrimSpace(s) != "" { - out = append(out, s) - } - } - return out, nil -} - -// newLineDataReader wraps a streaming body and exposes a JSON decoder that -// decodes successive objects from lines prefixed by "data: ". -// (no streaming decoder needed; we parse whole body lines) diff --git a/internal/llm/copilot_http_test.go b/internal/llm/copilot_http_test.go deleted file mode 100644 index 1371f71..0000000 --- a/internal/llm/copilot_http_test.go +++ /dev/null @@ -1,276 +0,0 @@ -package llm - -import ( - "context" - "encoding/base64" - "encoding/json" - "io" - "net" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - "time" -) - -type rtFunc2 func(*http.Request) (*http.Response, error) - -func (f rtFunc2) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } - -func TestCopilot_EnsureSession_AndChat_Success(t *testing.T) { - if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { - t.Skip("skip network-bound tests in restricted environments") - } - // Mock chat endpoint - chatSrv := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/chat/completions" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - _ = json.NewEncoder(w).Encode(map[string]any{"choices": []map[string]any{{"index": 0, "message": map[string]string{"role": "assistant", "content": "OK"}}}}) - })) - defer chatSrv.Close() - c := newCopilot(chatSrv.URL, "gpt-4o-mini", "APIKEY", f64p(0.1)).(copilotClient) - // Intercept token endpoint to return a session token - tr := rtFunc2(func(r *http.Request) (*http.Response, error) { - if r.URL.Host == "api.github.com" && r.URL.Path == "/copilot_internal/v2/token" { - rw := httptest.NewRecorder() - _ = json.NewEncoder(rw).Encode(map[string]string{"token": "tok"}) - res := rw.Result() - res.StatusCode = 200 - return res, nil - } - // Fallback to default transport for chatSrv - return http.DefaultTransport.RoundTrip(r) - }) - c.httpClient = &http.Client{Transport: tr, Timeout: 5 * time.Second} - out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}) - if err != nil || out != "OK" { - t.Fatalf("copilot chat failed: %v %q", err, out) - } -} - -func TestCopilot_HandleNon2xx(t *testing.T) { - b, _ := json.Marshal(map[string]any{"error": map[string]any{"message": "bad", "type": "invalid"}}) - resp := &http.Response{StatusCode: 400, Body: io.NopCloser(bytesReader(b))} - if err := handleCopilotNon2xx(resp, time.Now()); err == nil { - t.Fatalf("expected error") - } -} - -func TestCopilot_CodeCompletion_Success(t *testing.T) { - c := newCopilot("https://api.githubcopilot.com", "gpt-4o-mini", "API", f64p(0.1)).(copilotClient) - tr := rtFunc2(func(r *http.Request) (*http.Response, error) { - // Token endpoint - if r.URL.Host == "api.github.com" && r.URL.Path == "/copilot_internal/v2/token" { - rw := httptest.NewRecorder() - _ = json.NewEncoder(rw).Encode(map[string]string{"token": "tok"}) - res := rw.Result() - res.StatusCode = 200 - return res, nil - } - // Codex completion endpoint - if r.URL.Host == "copilot-proxy.githubusercontent.com" && strings.HasSuffix(r.URL.Path, "/v1/engines/copilot-codex/completions") { - rw := httptest.NewRecorder() - // two choices for index 0 and 1 - _, _ = rw.WriteString("data: {\"choices\":[{\"index\":0,\"text\":\"A\"}]}\n") - _, _ = rw.WriteString("data: {\"choices\":[{\"index\":1,\"text\":\"B\"}]}\n") - res := rw.Result() - res.StatusCode = 200 - return res, nil - } - return http.DefaultTransport.RoundTrip(r) - }) - c.httpClient = &http.Client{Transport: tr, Timeout: 5 * time.Second} - out, err := c.CodeCompletion(context.Background(), "p", "s", 2, "go", 0.1) - if err != nil || len(out) != 2 || out[0] != "A" || out[1] != "B" { - t.Fatalf("codex: %v %#v", err, out) - } -} - -func TestCopilot_Chat_MultiChoice_And_ErrorBody(t *testing.T) { - if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { - t.Skip("skip network-bound tests in restricted environments") - } - // Chat multi-choice: return two choices; client returns first content - srv := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{ - "choices": []map[string]any{ - {"index": 0, "finish_reason": "stop", "message": map[string]string{"role": "assistant", "content": "FIRST"}}, - {"index": 1, "finish_reason": "length", "message": map[string]string{"role": "assistant", "content": "SECOND"}}, - }, - }) - })) - defer srv.Close() - c := newCopilot(srv.URL, "gpt-4o-mini", "KEY", f64p(0.1)).(copilotClient) - // Token success - tr := rtFunc2(func(r *http.Request) (*http.Response, error) { - if r.URL.Host == "api.github.com" && r.URL.Path == "/copilot_internal/v2/token" { - rw := httptest.NewRecorder() - _ = json.NewEncoder(rw).Encode(map[string]string{"token": "tok"}) - res := rw.Result() - res.StatusCode = 200 - return res, nil - } - return http.DefaultTransport.RoundTrip(r) - }) - c.httpClient = &http.Client{Transport: tr, Timeout: 5 * time.Second} - out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}) - if err != nil || out != "FIRST" { - t.Fatalf("copilot multi-choice: %v %q", err, out) - } - - // Non-2xx with error body - srv2 := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(403) - _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": "denied", "type": "forbidden"}}) - })) - defer srv2.Close() - c2 := newCopilot(srv2.URL, "gpt-4o-mini", "KEY", f64p(0.1)).(copilotClient) - c2.httpClient = &http.Client{Transport: tr, Timeout: 5 * time.Second} - if _, err := c2.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}); err == nil { - t.Fatalf("expected error for copilot non-2xx with error body") - } -} - -func TestCopilot_Chat_NoChoices_Error(t *testing.T) { - if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { - t.Skip("skip network-bound tests in restricted environments") - } - srv := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{}}) - })) - defer srv.Close() - c := newCopilot(srv.URL, "gpt-4o-mini", "KEY", f64p(0.1)).(copilotClient) - tr := rtFunc2(func(r *http.Request) (*http.Response, error) { - if r.URL.Host == "api.github.com" && r.URL.Path == "/copilot_internal/v2/token" { - rw := httptest.NewRecorder() - _ = json.NewEncoder(rw).Encode(map[string]string{"token": "tok"}) - res := rw.Result() - res.StatusCode = 200 - return res, nil - } - return http.DefaultTransport.RoundTrip(r) - }) - c.httpClient = &http.Client{Transport: tr, Timeout: 5 * time.Second} - if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}); err == nil { - t.Fatalf("expected error when no choices returned") - } -} - -func TestCopilot_Chat_DecodeError_StatusOK(t *testing.T) { - if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { - t.Skip("skip network-bound tests in restricted environments") - } - // Chat returns 200 but invalid JSON; expect decode error - srv := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = io.WriteString(w, "{invalid") - })) - defer srv.Close() - c := newCopilot(srv.URL, "gpt-4o-mini", "KEY", f64p(0.1)).(copilotClient) - tr := rtFunc2(func(r *http.Request) (*http.Response, error) { - if r.URL.Host == "api.github.com" && r.URL.Path == "/copilot_internal/v2/token" { - rw := httptest.NewRecorder() - _ = json.NewEncoder(rw).Encode(map[string]string{"token": "tok"}) - res := rw.Result() - res.StatusCode = 200 - return res, nil - } - return http.DefaultTransport.RoundTrip(r) - }) - c.httpClient = &http.Client{Transport: tr, Timeout: 5 * time.Second} - if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}); err == nil { - t.Fatalf("expected decode error for invalid body") - } -} - -func TestCopilot_CodeCompletion_MalformedAndEmpty(t *testing.T) { - c := newCopilot("https://api.githubcopilot.com", "gpt-4o-mini", "API", f64p(0.1)).(copilotClient) - tr := rtFunc2(func(r *http.Request) (*http.Response, error) { - if r.URL.Host == "api.github.com" && r.URL.Path == "/copilot_internal/v2/token" { - rw := httptest.NewRecorder() - _ = json.NewEncoder(rw).Encode(map[string]string{"token": "tok"}) - res := rw.Result() - res.StatusCode = 200 - return res, nil - } - if r.URL.Host == "copilot-proxy.githubusercontent.com" && strings.HasSuffix(r.URL.Path, "/v1/engines/copilot-codex/completions") { - rw := httptest.NewRecorder() - // malformed line - _, _ = rw.WriteString("data: {bad}\n") - // done; should produce empty suggestions - _, _ = rw.WriteString("data: [DONE]\n") - res := rw.Result() - res.StatusCode = 200 - return res, nil - } - return http.DefaultTransport.RoundTrip(r) - }) - c.httpClient = &http.Client{Transport: tr, Timeout: 5 * time.Second} - out, err := c.CodeCompletion(context.Background(), "p", "s", 1, "go", 0.1) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(out) != 0 { - t.Fatalf("expected empty suggestions, got %#v", out) - } - - // Now include one good chunk after malformed - tr2 := rtFunc2(func(r *http.Request) (*http.Response, error) { - if r.URL.Host == "api.github.com" && r.URL.Path == "/copilot_internal/v2/token" { - rw := httptest.NewRecorder() - _ = json.NewEncoder(rw).Encode(map[string]string{"token": "tok"}) - res := rw.Result() - res.StatusCode = 200 - return res, nil - } - if r.URL.Host == "copilot-proxy.githubusercontent.com" && strings.HasSuffix(r.URL.Path, "/v1/engines/copilot-codex/completions") { - rw := httptest.NewRecorder() - _, _ = rw.WriteString("data: {bad}\n") - _, _ = rw.WriteString("data: {\"choices\":[{\"index\":0,\"text\":\"OK\"}]}\n") - _, _ = rw.WriteString("data: [DONE]\n") - res := rw.Result() - res.StatusCode = 200 - return res, nil - } - return http.DefaultTransport.RoundTrip(r) - }) - c.httpClient = &http.Client{Transport: tr2, Timeout: 5 * time.Second} - out2, err := c.CodeCompletion(context.Background(), "p", "s", 1, "go", 0.1) - if err != nil || len(out2) != 1 || out2[0] != "OK" { - t.Fatalf("unexpected: %v %#v", err, out2) - } -} - -func TestParseJWTExp_AndParseInt64(t *testing.T) { - // Valid base64 payload - payload := `{"exp": 1700000000}` - b := base64.RawURLEncoding.EncodeToString([]byte(payload)) - tok := "x." + b + ".y" - if tm := parseJWTExp(tok); tm.IsZero() { - t.Fatalf("expected non-zero time") - } - if n, err := parseInt64("123"); err != nil || n != 123 { - t.Fatalf("parseInt64: %v %d", err, n) - } -} - -func newIPv4Server(t *testing.T, handler http.Handler) *httptest.Server { - t.Helper() - l, err := net.Listen("tcp4", "127.0.0.1:0") - if err != nil { - t.Fatalf("failed to listen on tcp4: %v", err) - } - srv := &httptest.Server{ - Listener: l, - Config: &http.Server{Handler: handler}, - } - srv.Start() - return srv -} - -// bytesReader wraps a byte slice with an io.ReadCloser without importing extra. -type bytesReader []byte - -func (b bytesReader) Read(p []byte) (int, error) { n := copy(p, b); return n, io.EOF } -func (b bytesReader) Close() error { return nil } diff --git a/internal/llm/copilot_test.go b/internal/llm/copilot_test.go deleted file mode 100644 index 8f15347..0000000 --- a/internal/llm/copilot_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package llm - -import "testing" - -func TestBuildCopilotChatRequest_FieldsAndDefaults(t *testing.T) { - o := Options{ - Model: "gpt-x", - Temperature: 0, - MaxTokens: 123, - Stop: []string{"X"}, - } - - msgs := []Message{{Role: "user", Content: "q"}} - req := buildCopilotChatRequest(o, msgs, f64p(0.5)) - - if req.Model != "gpt-x" { - t.Fatalf("model mismatch: %q", req.Model) - } - - if req.Temperature == nil || *req.Temperature != 0.5 { - t.Fatalf("default temp not applied") - } - - if req.MaxTokens == nil || *req.MaxTokens != 123 { - t.Fatalf("max_tokens not applied") - } - - if len(req.Stop) != 1 || req.Stop[0] != "X" { - t.Fatalf("stop not applied") - } - - if len(req.Messages) != 1 || req.Messages[0].Content != "q" { - t.Fatalf("messages not copied") - } -} diff --git a/internal/llm/openai_temp_test.go b/internal/llm/openai_temp_test.go index 07abbd5..3d71b94 100644 --- a/internal/llm/openai_temp_test.go +++ b/internal/llm/openai_temp_test.go @@ -5,7 +5,7 @@ import "testing" func TestNewFromConfig_DefaultTemp_ByModel(t *testing.T) { // OpenAI, gpt-5.* → default temp 1.0 when not provided cfg := Config{Provider: "openai", OpenAIModel: "gpt-5.0-preview"} - c, err := NewFromConfig(cfg, "key", "", "", "") + c, err := NewFromConfig(cfg, "key", "", "") if err != nil { t.Fatalf("new: %v", err) } @@ -18,7 +18,7 @@ func TestNewFromConfig_DefaultTemp_ByModel(t *testing.T) { } // OpenAI, gpt-4.* → default temp 0.2 when not provided cfg2 := Config{Provider: "openai", OpenAIModel: "gpt-4.1"} - c2, err := NewFromConfig(cfg2, "key", "", "", "") + c2, err := NewFromConfig(cfg2, "key", "", "") if err != nil { t.Fatalf("new2: %v", err) } @@ -32,7 +32,7 @@ func TestNewFromConfig_DefaultTemp_UpgradeWhenGpt5AndDefault02(t *testing.T) { // Simulate app-default of 0.2 while selecting a gpt-5 model: should upgrade to 1.0 v := 0.2 cfg := Config{Provider: "openai", OpenAIModel: "gpt-5.0", OpenAITemperature: &v} - c, err := NewFromConfig(cfg, "key", "", "", "") + c, err := NewFromConfig(cfg, "key", "", "") if err != nil { t.Fatalf("new: %v", err) } diff --git a/internal/llm/provider.go b/internal/llm/provider.go index 297f1f3..8230b53 100644 --- a/internal/llm/provider.go +++ b/internal/llm/provider.go @@ -78,10 +78,6 @@ type Config struct { OllamaBaseURL string OllamaModel string OllamaTemperature *float64 - // Copilot options - CopilotBaseURL string - CopilotModel string - CopilotTemperature *float64 // Anthropic options AnthropicBaseURL string AnthropicModel string @@ -91,7 +87,7 @@ type Config struct { // NewFromConfig creates an LLM client using only the supplied configuration. // The OpenAI API key is supplied separately and may be read from the environment // by the caller; other environment-based configuration is not used. -func NewFromConfig(cfg Config, openAIAPIKey, openRouterAPIKey, copilotAPIKey, anthropicAPIKey string) (Client, error) { +func NewFromConfig(cfg Config, openAIAPIKey, openRouterAPIKey, anthropicAPIKey string) (Client, error) { p := strings.ToLower(strings.TrimSpace(cfg.Provider)) if p == "" { p = "openai" @@ -136,15 +132,6 @@ func NewFromConfig(cfg Config, openAIAPIKey, openRouterAPIKey, copilotAPIKey, an cfg.OllamaTemperature = &t } return newOllamaWithTimeout(cfg.OllamaBaseURL, cfg.OllamaModel, cfg.OllamaTemperature, cfg.RequestTimeout), nil - case "copilot": - if strings.TrimSpace(copilotAPIKey) == "" { - return nil, errors.New("missing COPILOT_API_KEY for provider copilot") - } - if cfg.CopilotTemperature == nil { - t := 0.2 - cfg.CopilotTemperature = &t - } - return newCopilotWithTimeout(cfg.CopilotBaseURL, cfg.CopilotModel, copilotAPIKey, cfg.CopilotTemperature, cfg.RequestTimeout), nil case "anthropic": if strings.TrimSpace(anthropicAPIKey) == "" { return nil, errors.New("missing ANTHROPIC_API_KEY for provider anthropic") diff --git a/internal/llm/provider_more2_test.go b/internal/llm/provider_more2_test.go deleted file mode 100644 index 86b149a..0000000 --- a/internal/llm/provider_more2_test.go +++ /dev/null @@ -1,12 +0,0 @@ -package llm - -import "testing" - -func TestNewFromConfig_Copilot(t *testing.T) { - t.Setenv("COPILOT_API_KEY", "x") - cfg := Config{Provider: "copilot", CopilotModel: "small"} - c, err := NewFromConfig(cfg, "", "", "x", "") - if err != nil || c == nil { - t.Fatalf("copilot provider failed: %v %v", c, err) - } -} diff --git a/internal/llm/provider_more_test.go b/internal/llm/provider_more_test.go index caad912..8d7b133 100644 --- a/internal/llm/provider_more_test.go +++ b/internal/llm/provider_more_test.go @@ -13,17 +13,11 @@ func TestWithOptions_Apply(t *testing.T) { } } -func TestNewFromConfig_Success_OpenAI_And_Copilot(t *testing.T) { +func TestNewFromConfig_Success_OpenAI(t *testing.T) { // OpenAI success oc := Config{Provider: "openai", OpenAIBaseURL: "http://x", OpenAIModel: "gpt"} - c, err := NewFromConfig(oc, "KEY", "", "", "") + c, err := NewFromConfig(oc, "KEY", "", "") if err != nil || c == nil || c.Name() != "openai" || c.DefaultModel() == "" { t.Fatalf("openai new: %v %v", c, err) } - // Copilot success - cc := Config{Provider: "copilot", CopilotBaseURL: "http://x", CopilotModel: "gpt-4o-mini"} - c2, err := NewFromConfig(cc, "", "", "KEY", "") - if err != nil || c2 == nil || c2.Name() != "copilot" || c2.DefaultModel() == "" { - t.Fatalf("copilot new: %v %v", c2, err) - } } diff --git a/internal/llm/provider_test.go b/internal/llm/provider_test.go index 46c7ea8..8ccba6e 100644 --- a/internal/llm/provider_test.go +++ b/internal/llm/provider_test.go @@ -6,15 +6,11 @@ import ( func TestNewFromConfig_DefaultsAndErrors(t *testing.T) { // Unknown provider - if _, err := NewFromConfig(Config{Provider: "bogus"}, "", "", "", ""); err == nil { + if _, err := NewFromConfig(Config{Provider: "bogus"}, "", "", ""); err == nil { t.Fatalf("expected error for unknown provider") } // OpenAI missing key - if _, err := NewFromConfig(Config{Provider: "openai", OpenAIModel: "g"}, "", "", "", ""); err == nil { - t.Fatalf("expected key error") - } - // Copilot missing key - if _, err := NewFromConfig(Config{Provider: "copilot", CopilotModel: "m"}, "", "", "", ""); err == nil { + if _, err := NewFromConfig(Config{Provider: "openai", OpenAIModel: "g"}, "", "", ""); err == nil { t.Fatalf("expected key error") } } diff --git a/internal/llmutils/client.go b/internal/llmutils/client.go index de65935..c8d9a90 100644 --- a/internal/llmutils/client.go +++ b/internal/llmutils/client.go @@ -22,9 +22,6 @@ func NewClientFromApp(cfg appconfig.App) (llm.Client, error) { OllamaBaseURL: cfg.OllamaBaseURL, OllamaModel: cfg.OllamaModel, OllamaTemperature: cfg.OllamaTemperature, - CopilotBaseURL: cfg.CopilotBaseURL, - CopilotModel: cfg.CopilotModel, - CopilotTemperature: cfg.CopilotTemperature, AnthropicBaseURL: cfg.AnthropicBaseURL, AnthropicModel: cfg.AnthropicModel, AnthropicTemperature: cfg.AnthropicTemperature, @@ -37,13 +34,9 @@ func NewClientFromApp(cfg appconfig.App) (llm.Client, error) { if strings.TrimSpace(orKey) == "" { orKey = os.Getenv("OPENROUTER_API_KEY") } - cpKey := os.Getenv("HEXAI_COPILOT_API_KEY") - if strings.TrimSpace(cpKey) == "" { - cpKey = os.Getenv("COPILOT_API_KEY") - } anKey := os.Getenv("HEXAI_ANTHROPIC_API_KEY") if strings.TrimSpace(anKey) == "" { anKey = os.Getenv("ANTHROPIC_API_KEY") } - return llm.NewFromConfig(llmCfg, oaKey, orKey, cpKey, anKey) + return llm.NewFromConfig(llmCfg, oaKey, orKey, anKey) } diff --git a/internal/lsp/handlers_utils.go b/internal/lsp/handlers_utils.go index 6260acd..1ea36c8 100644 --- a/internal/lsp/handlers_utils.go +++ b/internal/lsp/handlers_utils.go @@ -110,8 +110,6 @@ func resolveDefaultModel(cfg appconfig.App, provider string) string { switch provider { case "ollama": return strings.TrimSpace(cfg.OllamaModel) - case "copilot": - return strings.TrimSpace(cfg.CopilotModel) case "anthropic": return strings.TrimSpace(cfg.AnthropicModel) case "openrouter": diff --git a/internal/lsp/llm_request_opts_test.go b/internal/lsp/llm_request_opts_test.go index f4d31b9..ad87cd4 100644 --- a/internal/lsp/llm_request_opts_test.go +++ b/internal/lsp/llm_request_opts_test.go @@ -44,10 +44,10 @@ func TestBuildRequestSpecs_MultiEntries(t *testing.T) { s := newTestServer() s.cfg.CompletionConfigs = []appconfig.SurfaceConfig{ {Provider: "openai", Model: "gpt-4o"}, - {Provider: "copilot", Model: "cpt", Temperature: floatPtr(0.4)}, + {Provider: "anthropic", Model: "claude", Temperature: floatPtr(0.4)}, } s.cfg.OpenAIModel = "gpt-3.5" - s.cfg.CopilotModel = "cpt-base" + s.cfg.AnthropicModel = "claude-base" s.cfg.MaxTokens = 256 specs := s.buildRequestSpecs(surfaceCompletion) if len(specs) != 2 { @@ -56,7 +56,7 @@ func TestBuildRequestSpecs_MultiEntries(t *testing.T) { if specs[0].provider != "openai" || specs[0].index != 0 { t.Fatalf("unexpected first spec: %+v", specs[0]) } - if specs[1].provider != "copilot" || specs[1].index != 1 { + if specs[1].provider != "anthropic" || specs[1].index != 1 { t.Fatalf("unexpected second spec: %+v", specs[1]) } var opts1, opts2 llm.Options @@ -69,7 +69,7 @@ func TestBuildRequestSpecs_MultiEntries(t *testing.T) { if opts1.Model != "gpt-4o" || opts1.MaxTokens != 256 { t.Fatalf("unexpected opts1: %+v", opts1) } - if opts2.Model != "cpt" || opts2.Temperature != 0.4 { + if opts2.Model != "claude" || opts2.Temperature != 0.4 { t.Fatalf("unexpected opts2: %+v", opts2) } } diff --git a/internal/lsp/server.go b/internal/lsp/server.go index bbee64f..c226ab4 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -230,9 +230,6 @@ func newClientForProvider(cfg appconfig.App, provider string) (llm.Client, error OllamaBaseURL: cfg.OllamaBaseURL, OllamaModel: cfg.OllamaModel, OllamaTemperature: cfg.OllamaTemperature, - CopilotBaseURL: cfg.CopilotBaseURL, - CopilotModel: cfg.CopilotModel, - CopilotTemperature: cfg.CopilotTemperature, AnthropicBaseURL: cfg.AnthropicBaseURL, AnthropicModel: cfg.AnthropicModel, AnthropicTemperature: cfg.AnthropicTemperature, @@ -245,15 +242,11 @@ func newClientForProvider(cfg appconfig.App, provider string) (llm.Client, error if orKey == "" { orKey = strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY")) } - cpKey := strings.TrimSpace(os.Getenv("HEXAI_COPILOT_API_KEY")) - if cpKey == "" { - cpKey = strings.TrimSpace(os.Getenv("COPILOT_API_KEY")) - } anKey := strings.TrimSpace(os.Getenv("HEXAI_ANTHROPIC_API_KEY")) if anKey == "" { anKey = strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) } - return llm.NewFromConfig(llmCfg, oaKey, orKey, cpKey, anKey) + return llm.NewFromConfig(llmCfg, oaKey, orKey, anKey) } func (s *Server) clientFor(spec requestSpec) llm.Client { @@ -296,12 +289,6 @@ func (s *Server) clientFor(spec requestSpec) llm.Client { } else if spec.fallbackModel != "" { cfg.OpenRouterModel = spec.fallbackModel } - case "copilot": - if modelOverride != "" { - cfg.CopilotModel = modelOverride - } else if spec.fallbackModel != "" { - cfg.CopilotModel = spec.fallbackModel - } case "ollama": if modelOverride != "" { cfg.OllamaModel = modelOverride diff --git a/internal/runtimeconfig/store_test.go b/internal/runtimeconfig/store_test.go index 0a0183a..168d2cd 100644 --- a/internal/runtimeconfig/store_test.go +++ b/internal/runtimeconfig/store_test.go @@ -99,7 +99,7 @@ func TestStoreReloadLogsSummary(t *testing.T) { func TestDiff_SurfaceModel(t *testing.T) { oldCfg := appconfig.App{CompletionConfigs: []appconfig.SurfaceConfig{{Provider: "openai", Model: "gpt-4o"}}} - newCfg := appconfig.App{CompletionConfigs: []appconfig.SurfaceConfig{{Provider: "copilot", Model: "gpt-4.1"}}} + newCfg := appconfig.App{CompletionConfigs: []appconfig.SurfaceConfig{{Provider: "anthropic", Model: "claude-3-5-sonnet"}}} changes := Diff(oldCfg, newCfg) if len(changes) == 0 { t.Fatalf("expected diff entries, got none") @@ -107,7 +107,7 @@ func TestDiff_SurfaceModel(t *testing.T) { found := false for _, ch := range changes { if ch.Key == "completion_configs" { - if !strings.Contains(ch.Old, "gpt-4o") || !strings.Contains(ch.New, "gpt-4.1") { + if !strings.Contains(ch.Old, "gpt-4o") || !strings.Contains(ch.New, "claude-3-5-sonnet") { t.Fatalf("unexpected diff contents: %+v", ch) } found = true |
