Skip to content

Commit bf5c536

Browse files
committed
Improve credential setup: provider-first flow and prefix learning
Stop multi-provider probe disambiguation from overriding explicit gateway selection. Record learned key prefixes after successful saves and use them to rank future resolve hints. Add Gemini AQ. prefix and document the provider-first Hawk setup path.
1 parent 9eec42b commit bf5c536

12 files changed

Lines changed: 535 additions & 41 deletions

File tree

catalog/live/fetchers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const (
1717
DefaultZAIBaseURL = "https://api.z.ai/api/paas/v4"
1818
DefaultOpenAIBaseURL = "https://api.openai.com/v1"
1919
DefaultGrokBaseURL = "https://api.x.ai/v1"
20-
DefaultOpenCodeGoBaseURL = "https://api.opencodego.ai/v1"
20+
DefaultOpenCodeGoBaseURL = "https://opencode.ai/zen/go/v1"
2121
DefaultKimiBaseURL = "https://api.moonshot.ai/v1"
2222
DefaultXiaomiBaseURL = "https://api.xiaomimimo.com/v1"
2323
)

catalog/registry/provider_spec_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ func TestOpenCodeGo_HasProbeBaseURL(t *testing.T) {
3333
if spec.ProbeBaseURL == "" {
3434
t.Fatal("opencodego must have a ProbeBaseURL")
3535
}
36+
if spec.ProbeBaseURL != "https://opencode.ai/zen/go/v1" {
37+
t.Fatalf("opencodego probe base URL = %q", spec.ProbeBaseURL)
38+
}
3639
if spec.ProbeKind != registry.ProbeOpenAIModels {
3740
t.Fatalf("opencodego probe kind = %q", spec.ProbeKind)
3841
}

catalog/registry/providers.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ func providerSpecs() []ProviderSpec {
2323
},
2424
{
2525
ProviderID: "openai", DisplayName: "OpenAI", DeploymentID: "openai-direct", SortOrder: 2,
26-
RequiresKey: true, CredentialEnv: "OPENAI_API_KEY", KeyPrefixes: []string{"sk-proj-", "sk-svcacct-", "sk-"},
26+
RequiresKey: true, CredentialEnv: "OPENAI_API_KEY", KeyPrefixes: []string{"sk-proj-", "sk-svcacct-"},
2727
BaseURLEnv: []string{"OPENAI_BASE_URL", "OPENAI_API_BASE"},
2828
ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.openai.com/v1",
2929
ModelStrategy: StrategyRemoteThenLive, PreferLiveMerge: true,
@@ -32,7 +32,7 @@ func providerSpecs() []ProviderSpec {
3232
},
3333
{
3434
ProviderID: "gemini", DisplayName: "Google Gemini", DeploymentID: "gemini-direct", SortOrder: 3,
35-
RequiresKey: true, CredentialEnv: "GEMINI_API_KEY", KeyPrefixes: []string{"AIza"},
35+
RequiresKey: true, CredentialEnv: "GEMINI_API_KEY", KeyPrefixes: []string{"AIza", "AQ."},
3636
BaseURLEnv: []string{"GEMINI_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"},
3737
ProbeKind: ProbeGemini, ModelStrategy: StrategyRemoteThenLive, PreferLiveMerge: true,
3838
LiveFetcherKey: "gemini", LiveCatalogKey: "gemini",
@@ -79,7 +79,7 @@ func providerSpecs() []ProviderSpec {
7979
RequiresKey: true, CredentialEnv: "OPENCODEGO_API_KEY", KeyPrefixes: []string{"ocg_"},
8080
BaseURLEnv: []string{"OPENCODEGO_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"},
8181
ProbeKind: ProbeOpenAIModels,
82-
ProbeBaseURL: "https://api.opencodego.ai/v1",
82+
ProbeBaseURL: "https://opencode.ai/zen/go/v1",
8383
ModelStrategy: StrategyRemoteThenLive, PreferLiveMerge: true,
8484
LiveFetcherKey: "opencodego", LiveCatalogKey: "opencodego",
8585
APIProtocolID: "openai-chat-completions", AdapterID: "opencodego",

config/credential/inference_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ func TestInferCredentialsFromAPIKey_OpenAI(t *testing.T) {
4545
}
4646
}
4747

48+
func TestInferCredentialsFromAPIKey_GenericOpenAICompatible(t *testing.T) {
49+
got := InferCredentialsFromAPIKey(ContextWithoutProbeDisambiguation(context.Background()), "sk-test-key-that-could-belong-to-any-compatible-provider")
50+
if len(got) != 0 {
51+
t.Fatalf("generic sk- keys should not infer a provider, got %#v", got)
52+
}
53+
}
54+
4855
func TestInferCredentialsFromAPIKey_Gemini(t *testing.T) {
4956
got := InferCredentialsFromAPIKey(context.Background(), "AIzaSyD-test-key-1234567890")
5057
if len(got) == 0 {

config/credential/learned.go

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
package credential
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"sync"
9+
)
10+
11+
const (
12+
learnedPrefixesFile = "learned_credential_prefixes.json"
13+
learnedPrefixMinLen = 4
14+
learnedPrefixMaxLen = 12
15+
learnedMaxPerProvider = 24
16+
)
17+
18+
type learnedPrefixFile struct {
19+
Version int `json:"version"`
20+
Providers map[string]map[string]int `json:"providers"`
21+
}
22+
23+
var (
24+
learnedMu sync.Mutex
25+
learnedCached *learnedPrefixFile
26+
)
27+
28+
// RecordLearnedCredential increments local prefix stats after a successful key save.
29+
// Only a short leading prefix is stored — never the full secret.
30+
func RecordLearnedCredential(providerID, secret string) {
31+
providerID = strings.TrimSpace(providerID)
32+
prefix := extractLearningPrefix(secret)
33+
if providerID == "" || prefix == "" {
34+
return
35+
}
36+
learnedMu.Lock()
37+
defer learnedMu.Unlock()
38+
data := loadLearnedLocked()
39+
if data.Providers == nil {
40+
data.Providers = map[string]map[string]int{}
41+
}
42+
counts := data.Providers[providerID]
43+
if counts == nil {
44+
counts = map[string]int{}
45+
data.Providers[providerID] = counts
46+
}
47+
counts[prefix]++
48+
pruneLearnedCounts(counts)
49+
_ = saveLearnedLocked(data)
50+
learnedCached = data
51+
}
52+
53+
func learnedPrefixBoost(secret string) map[string]int {
54+
secret = strings.TrimSpace(secret)
55+
if secret == "" {
56+
return nil
57+
}
58+
learnedMu.Lock()
59+
data := loadLearnedLocked()
60+
learnedMu.Unlock()
61+
if data == nil || len(data.Providers) == 0 {
62+
return nil
63+
}
64+
out := map[string]int{}
65+
for providerID, counts := range data.Providers {
66+
for learned, n := range counts {
67+
if n <= 0 || learned == "" {
68+
continue
69+
}
70+
if !strings.HasPrefix(secret, learned) {
71+
continue
72+
}
73+
boost := n * 10
74+
if boost > 120 {
75+
boost = 120
76+
}
77+
if out[providerID] < boost {
78+
out[providerID] = boost
79+
}
80+
}
81+
}
82+
return out
83+
}
84+
85+
// extractLearningPrefix returns a short, non-secret fingerprint from the start of a key.
86+
func extractLearningPrefix(secret string) string {
87+
secret = strings.TrimSpace(secret)
88+
if len(secret) < learnedPrefixMinLen {
89+
return ""
90+
}
91+
max := learnedPrefixMaxLen
92+
if len(secret) < max {
93+
max = len(secret)
94+
}
95+
var b strings.Builder
96+
for i := 0; i < max; i++ {
97+
r := rune(secret[i])
98+
if r == '-' || r == '_' || r == '.' ||
99+
(r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
100+
b.WriteRune(r)
101+
continue
102+
}
103+
break
104+
}
105+
out := b.String()
106+
if len(out) < learnedPrefixMinLen {
107+
return ""
108+
}
109+
return out
110+
}
111+
112+
func pruneLearnedCounts(counts map[string]int) {
113+
if len(counts) <= learnedMaxPerProvider {
114+
return
115+
}
116+
type kv struct {
117+
k string
118+
v int
119+
}
120+
var all []kv
121+
for k, v := range counts {
122+
all = append(all, kv{k, v})
123+
}
124+
for i := 0; i < len(all); i++ {
125+
for j := i + 1; j < len(all); j++ {
126+
if all[j].v < all[i].v {
127+
all[i], all[j] = all[j], all[i]
128+
}
129+
}
130+
}
131+
keep := map[string]struct{}{}
132+
for i := 0; i < learnedMaxPerProvider && i < len(all); i++ {
133+
keep[all[i].k] = struct{}{}
134+
}
135+
for k := range counts {
136+
if _, ok := keep[k]; !ok {
137+
delete(counts, k)
138+
}
139+
}
140+
}
141+
142+
func learnedHawkConfigDir() string {
143+
home, err := os.UserHomeDir()
144+
if err != nil || home == "" {
145+
return ".hawk"
146+
}
147+
return filepath.Join(home, ".hawk")
148+
}
149+
150+
func learnedPrefixesPath() string {
151+
return filepath.Join(learnedHawkConfigDir(), learnedPrefixesFile)
152+
}
153+
154+
func loadLearnedLocked() *learnedPrefixFile {
155+
if learnedCached != nil {
156+
return learnedCached
157+
}
158+
path := learnedPrefixesPath()
159+
data, err := os.ReadFile(path)
160+
if err != nil {
161+
learnedCached = &learnedPrefixFile{Version: 1, Providers: map[string]map[string]int{}}
162+
return learnedCached
163+
}
164+
var f learnedPrefixFile
165+
if json.Unmarshal(data, &f) != nil || f.Providers == nil {
166+
learnedCached = &learnedPrefixFile{Version: 1, Providers: map[string]map[string]int{}}
167+
return learnedCached
168+
}
169+
if f.Version == 0 {
170+
f.Version = 1
171+
}
172+
learnedCached = &f
173+
return learnedCached
174+
}
175+
176+
func saveLearnedLocked(f *learnedPrefixFile) error {
177+
if f == nil {
178+
return nil
179+
}
180+
if f.Version == 0 {
181+
f.Version = 1
182+
}
183+
dir := learnedHawkConfigDir()
184+
if err := os.MkdirAll(dir, 0o700); err != nil {
185+
return err
186+
}
187+
raw, err := json.MarshalIndent(f, "", " ")
188+
if err != nil {
189+
return err
190+
}
191+
return os.WriteFile(learnedPrefixesPath(), raw, 0o600)
192+
}
193+
194+
// InvalidateLearnedPrefixCache clears the in-memory learned-prefix snapshot (tests).
195+
func InvalidateLearnedPrefixCache() {
196+
learnedMu.Lock()
197+
learnedCached = nil
198+
learnedMu.Unlock()
199+
}
200+
201+
// isGenericOpenAIShapedKey reports OpenAI-style keys without a known vendor prefix.
202+
func isGenericOpenAIShapedKey(secret string) bool {
203+
secret = strings.TrimSpace(secret)
204+
if !strings.HasPrefix(secret, "sk-") {
205+
return false
206+
}
207+
if strings.HasPrefix(secret, "sk-ant-") ||
208+
strings.HasPrefix(secret, "sk-or-") ||
209+
strings.HasPrefix(secret, "sk-proj-") ||
210+
strings.HasPrefix(secret, "sk-svcacct-") {
211+
return false
212+
}
213+
return true
214+
}

config/credential/learned_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package credential
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestExtractLearningPrefix(t *testing.T) {
10+
got := extractLearningPrefix("AQ.abc123-secret")
11+
if len(got) < 4 || got[:3] != "AQ." {
12+
t.Fatalf("prefix = %q", got)
13+
}
14+
if extractLearningPrefix("ab") != "" {
15+
t.Fatal("expected empty for short secret")
16+
}
17+
}
18+
19+
func TestRecordLearnedCredential_BoostsInference(t *testing.T) {
20+
home := t.TempDir()
21+
t.Setenv("HOME", home)
22+
_ = os.MkdirAll(filepath.Join(home, ".hawk"), 0o700)
23+
InvalidateLearnedPrefixCache()
24+
25+
RecordLearnedCredential("gemini", "AQ.test-key-1234567890")
26+
InvalidateLearnedPrefixCache()
27+
28+
boost := learnedPrefixBoost("AQ.test-key-9999999999")
29+
if boost["gemini"] == 0 {
30+
t.Fatal("expected learned boost for gemini after record")
31+
}
32+
33+
ctx := ContextWithoutProbeDisambiguation(t.Context())
34+
res := ResolveCredential(ctx, "AQ.test-key-9999999999")
35+
if !res.FormatOK {
36+
t.Fatalf("resolve failed: %s", res.FormatError)
37+
}
38+
if res.Providers[0].ProviderID != "gemini" || !res.Providers[0].Inferred {
39+
t.Fatalf("expected gemini inferred first, got %#v", res.Providers[0])
40+
}
41+
}
42+
43+
func TestResolveCredential_GeminiAQPrefix(t *testing.T) {
44+
ctx := ContextWithoutProbeDisambiguation(t.Context())
45+
res := ResolveCredential(ctx, "AQ.test-gemini-key-1234567890")
46+
if !res.FormatOK {
47+
t.Fatalf("format: %s", res.FormatError)
48+
}
49+
if res.Providers[0].ProviderID != "gemini" || !res.Providers[0].Inferred {
50+
t.Fatalf("top = %#v, want inferred gemini", res.Providers[0])
51+
}
52+
}

0 commit comments

Comments
 (0)