forked from opencode/ocgo
Compare commits
3 commits
5354905c82
...
643efdabb6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
643efdabb6 | ||
| 1b685002b8 | |||
| db1dd25a9b |
1 changed files with 138 additions and 44 deletions
182
cmd/ocgo/main.go
182
cmd/ocgo/main.go
|
|
@ -32,10 +32,11 @@ const (
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key,omitempty"`
|
||||||
Host string `json:"host"`
|
APIKeys []string `json:"api_keys,omitempty"`
|
||||||
Port int `json:"port"`
|
Host string `json:"host"`
|
||||||
Model string `json:"model"`
|
Port int `json:"port"`
|
||||||
|
Model string `json:"model"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnthropicRequest struct {
|
type AnthropicRequest struct {
|
||||||
|
|
@ -148,30 +149,38 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupCmd() *cobra.Command {
|
func setupCmd() *cobra.Command {
|
||||||
var key string
|
var apiKeys string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "setup",
|
Use: "setup",
|
||||||
Short: "Save your OpenCode Go API key",
|
Short: "Save your OpenCode Go API key(s)",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if strings.TrimSpace(key) == "" {
|
if apiKeys == "" {
|
||||||
key = os.Getenv("OCGO_API_KEY")
|
apiKeys, _ = cmd.Flags().GetString("api-key")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(key) == "" {
|
if strings.TrimSpace(apiKeys) == "" {
|
||||||
fmt.Print("OpenCode Go API key: ")
|
apiKeys = os.Getenv("OCGO_API_KEYS")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(apiKeys) == "" {
|
||||||
|
apiKeys = os.Getenv("OCGO_API_KEY")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(apiKeys) == "" {
|
||||||
|
fmt.Print("OpenCode Go API key(s) (comma-separated): ")
|
||||||
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
if err != nil && line == "" {
|
if err != nil && line == "" {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
key = line
|
apiKeys = line
|
||||||
}
|
}
|
||||||
cfg := Config{APIKey: strings.TrimSpace(key), Host: defaultHost, Port: defaultPort}
|
keys := parseKeys(strings.TrimSpace(apiKeys))
|
||||||
if cfg.APIKey == "" {
|
if len(keys) == 0 {
|
||||||
return errors.New("API key cannot be empty")
|
return errors.New("at least one API key is required")
|
||||||
}
|
}
|
||||||
|
cfg := Config{APIKeys: keys, Host: defaultHost, Port: defaultPort}
|
||||||
return saveConfig(cfg)
|
return saveConfig(cfg)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&key, "api-key", "", "OpenCode Go API key")
|
cmd.Flags().StringVar(&apiKeys, "api-keys", "", "OpenCode Go API key(s), comma-separated")
|
||||||
|
cmd.Flags().String("api-key", "", "OpenCode Go API key (single)")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,6 +197,16 @@ func knownModelIDs() []string {
|
||||||
return []string{"glm-5.1", "glm-5", "kimi-k2.6", "kimi-k2.5", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5", "deepseek-v4-pro[1M]", "deepseek-v4-flash[1M]", "qwen3.6-plus", "qwen3.5-plus"}
|
return []string{"glm-5.1", "glm-5", "kimi-k2.6", "kimi-k2.5", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5", "deepseek-v4-pro[1M]", "deepseek-v4-flash[1M]", "qwen3.6-plus", "qwen3.5-plus"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isKnownModelID(model string) bool {
|
||||||
|
cleaned := cleanModelName(model)
|
||||||
|
for _, m := range knownModelIDs() {
|
||||||
|
if cleanModelName(m) == cleaned {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func cleanModelName(model string) string {
|
func cleanModelName(model string) string {
|
||||||
model = strings.TrimSuffix(model, "[1M]")
|
model = strings.TrimSuffix(model, "[1M]")
|
||||||
model = strings.TrimSuffix(model, "[128K]")
|
model = strings.TrimSuffix(model, "[128K]")
|
||||||
|
|
@ -403,14 +422,7 @@ func proxyMessages(w http.ResponseWriter, r *http.Request, cfg Config) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(or)
|
body, _ := json.Marshal(or)
|
||||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, openAIURL, bytes.NewReader(body))
|
resp, err := cfg.postWithRetry(r.Context(), openAIURL, "application/json", body)
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
resp, err := (&http.Client{Timeout: 10 * time.Minute}).Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
|
|
@ -448,20 +460,13 @@ func proxyChatCompletions(w http.ResponseWriter, r *http.Request, cfg Config) {
|
||||||
dm := cleanModelName(cfg.Model)
|
dm := cleanModelName(cfg.Model)
|
||||||
var reqMap map[string]any
|
var reqMap map[string]any
|
||||||
if json.Unmarshal(body, &reqMap) == nil {
|
if json.Unmarshal(body, &reqMap) == nil {
|
||||||
if m, _ := reqMap["model"].(string); m == "" || strings.HasPrefix(m, "claude-") {
|
if m, _ := reqMap["model"].(string); m == "" || !isKnownModelID(m) {
|
||||||
reqMap["model"] = dm
|
reqMap["model"] = dm
|
||||||
body, _ = json.Marshal(reqMap)
|
body, _ = json.Marshal(reqMap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, openAIURL, bytes.NewReader(body))
|
resp, err := cfg.postWithRetry(r.Context(), openAIURL, "application/json", body)
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
resp, err := (&http.Client{Timeout: 10 * time.Minute}).Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
|
|
@ -488,14 +493,7 @@ func proxyResponses(w http.ResponseWriter, r *http.Request, cfg Config) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(or)
|
body, _ := json.Marshal(or)
|
||||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, openAIURL, bytes.NewReader(body))
|
resp, err := cfg.postWithRetry(r.Context(), openAIURL, "application/json", body)
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
resp, err := (&http.Client{Timeout: 10 * time.Minute}).Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
|
|
@ -627,7 +625,7 @@ func stripRawChatImageDetails(req map[string]any) bool {
|
||||||
|
|
||||||
func convertRequest(ar AnthropicRequest, defaultModel string) OAIRequest {
|
func convertRequest(ar AnthropicRequest, defaultModel string) OAIRequest {
|
||||||
model := cleanModelName(ar.Model)
|
model := cleanModelName(ar.Model)
|
||||||
if model == "" || strings.HasPrefix(model, "claude-") || defaultModel != "" {
|
if model == "" || !isKnownModelID(model) {
|
||||||
model = cleanModelName(defaultModel)
|
model = cleanModelName(defaultModel)
|
||||||
}
|
}
|
||||||
if model == "" {
|
if model == "" {
|
||||||
|
|
@ -648,7 +646,7 @@ func convertRequest(ar AnthropicRequest, defaultModel string) OAIRequest {
|
||||||
|
|
||||||
func responsesToChat(rr ResponsesRequest, defaultModel string) OAIRequest {
|
func responsesToChat(rr ResponsesRequest, defaultModel string) OAIRequest {
|
||||||
model := cleanModelName(rr.Model)
|
model := cleanModelName(rr.Model)
|
||||||
if model == "" || defaultModel != "" {
|
if model == "" || !isKnownModelID(model) {
|
||||||
model = cleanModelName(defaultModel)
|
model = cleanModelName(defaultModel)
|
||||||
}
|
}
|
||||||
if model == "" {
|
if model == "" {
|
||||||
|
|
@ -1512,6 +1510,76 @@ func startServerProcess(detached bool, model string) (*exec.Cmd, error) {
|
||||||
func configDir() string { home, _ := os.UserHomeDir(); return filepath.Join(home, ".config", "ocgo") }
|
func configDir() string { home, _ := os.UserHomeDir(); return filepath.Join(home, ".config", "ocgo") }
|
||||||
func configFile() string { return filepath.Join(configDir(), "config.json") }
|
func configFile() string { return filepath.Join(configDir(), "config.json") }
|
||||||
func pidFile() string { return filepath.Join(configDir(), "ocgo.pid") }
|
func pidFile() string { return filepath.Join(configDir(), "ocgo.pid") }
|
||||||
|
func keyIndexFile() string { return filepath.Join(configDir(), "key-index") }
|
||||||
|
|
||||||
|
func readKeyIndex() int {
|
||||||
|
b, err := os.ReadFile(keyIndexFile())
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var idx int
|
||||||
|
fmt.Sscanf(string(b), "%d", &idx)
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeKeyIndex(idx int) error {
|
||||||
|
return os.WriteFile(keyIndexFile(), []byte(fmt.Sprint(idx)), 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg Config) activeKeys() []string {
|
||||||
|
if len(cfg.APIKeys) > 0 {
|
||||||
|
return cfg.APIKeys
|
||||||
|
}
|
||||||
|
if cfg.APIKey != "" {
|
||||||
|
return []string{cfg.APIKey}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg Config) currentKey() string {
|
||||||
|
keys := cfg.activeKeys()
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
idx := readKeyIndex() % len(keys)
|
||||||
|
return keys[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg Config) rotateKey() string {
|
||||||
|
keys := cfg.activeKeys()
|
||||||
|
if len(keys) <= 1 {
|
||||||
|
return cfg.currentKey()
|
||||||
|
}
|
||||||
|
idx := readKeyIndex()
|
||||||
|
idx = (idx + 1) % len(keys)
|
||||||
|
_ = writeKeyIndex(idx)
|
||||||
|
return keys[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg Config) postWithRetry(ctx context.Context, url, contentType string, body []byte) (*http.Response, error) {
|
||||||
|
client := &http.Client{Timeout: 10 * time.Minute}
|
||||||
|
keys := cfg.activeKeys()
|
||||||
|
for i := 0; i < len(keys); i++ {
|
||||||
|
key := cfg.currentKey()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+key)
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests && i < len(keys)-1 {
|
||||||
|
resp.Body.Close()
|
||||||
|
cfg.rotateKey()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
panic("unreachable")
|
||||||
|
}
|
||||||
|
|
||||||
func codexConfigFile() string {
|
func codexConfigFile() string {
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
|
|
@ -1664,6 +1732,12 @@ func versionParts(v string) [3]int {
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveConfig(cfg Config) error {
|
func saveConfig(cfg Config) error {
|
||||||
|
// Write api_key for backward compatibility with older ocgo versions
|
||||||
|
if len(cfg.APIKeys) > 0 {
|
||||||
|
cfg.APIKey = cfg.APIKeys[0]
|
||||||
|
} else {
|
||||||
|
cfg.APIKey = ""
|
||||||
|
}
|
||||||
if err := os.MkdirAll(configDir(), 0755); err != nil {
|
if err := os.MkdirAll(configDir(), 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -1676,12 +1750,21 @@ func saveConfig(cfg Config) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() (Config, error) {
|
func loadConfig() (Config, error) {
|
||||||
cfg := Config{Host: defaultHost, Port: defaultPort, APIKey: os.Getenv("OCGO_API_KEY")}
|
cfg := Config{Host: defaultHost, Port: defaultPort}
|
||||||
b, err := os.ReadFile(configFile())
|
b, err := os.ReadFile(configFile())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_ = json.Unmarshal(b, &cfg)
|
_ = json.Unmarshal(b, &cfg)
|
||||||
}
|
}
|
||||||
if cfg.APIKey == "" {
|
if envKey := os.Getenv("OCGO_API_KEY"); envKey != "" {
|
||||||
|
cfg.APIKey = envKey
|
||||||
|
}
|
||||||
|
if envKeys := os.Getenv("OCGO_API_KEYS"); envKeys != "" {
|
||||||
|
cfg.APIKeys = parseKeys(envKeys)
|
||||||
|
}
|
||||||
|
if len(cfg.APIKeys) == 0 && cfg.APIKey != "" {
|
||||||
|
cfg.APIKeys = []string{cfg.APIKey}
|
||||||
|
}
|
||||||
|
if len(cfg.APIKeys) == 0 {
|
||||||
return cfg, errors.New("missing API key; run: ocgo setup")
|
return cfg, errors.New("missing API key; run: ocgo setup")
|
||||||
}
|
}
|
||||||
if cfg.Host == "" {
|
if cfg.Host == "" {
|
||||||
|
|
@ -1693,6 +1776,17 @@ func loadConfig() (Config, error) {
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseKeys(s string) []string {
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
var keys []string
|
||||||
|
for _, p := range parts {
|
||||||
|
if k := strings.TrimSpace(p); k != "" {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
func readPID() (int, error) {
|
func readPID() (int, error) {
|
||||||
b, err := os.ReadFile(pidFile())
|
b, err := os.ReadFile(pidFile())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue