Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39c415ebaa | |||
|
|
643efdabb6 | ||
| 1b685002b8 | |||
| db1dd25a9b | |||
|
|
7fdcf01534 | ||
|
|
582913dfcc | ||
|
|
23ebb2b09f | ||
|
|
ce6eef602e | ||
|
|
cc7e1e8921 |
7 changed files with 645 additions and 108 deletions
8
Makefile
8
Makefile
|
|
@ -1,12 +1,14 @@
|
||||||
.PHONY: build run test clean install release
|
.PHONY: build run test clean install release
|
||||||
|
|
||||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
|
EXE := $(shell go env GOEXE)
|
||||||
|
OCGO_BIN := bin/ocgo$(EXE)
|
||||||
GOBIN := $(shell go env GOBIN)
|
GOBIN := $(shell go env GOBIN)
|
||||||
GOPATH := $(shell go env GOPATH)
|
GOPATH := $(shell go env GOPATH)
|
||||||
INSTALL_DIR := $(if $(GOBIN),$(GOBIN),$(GOPATH)/bin)
|
INSTALL_DIR := $(HOME)/.local/bin
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -ldflags "-X main.version=$(VERSION)" -o bin/ocgo ./cmd/ocgo
|
go build -ldflags "-X main.version=$(VERSION)" -o $(OCGO_BIN) ./cmd/ocgo
|
||||||
|
|
||||||
run:
|
run:
|
||||||
go run ./cmd/ocgo
|
go run ./cmd/ocgo
|
||||||
|
|
@ -19,7 +21,7 @@ clean:
|
||||||
|
|
||||||
install: build
|
install: build
|
||||||
mkdir -p "$(INSTALL_DIR)"
|
mkdir -p "$(INSTALL_DIR)"
|
||||||
install -m 0755 bin/ocgo "$(INSTALL_DIR)/ocgo"
|
install -m 0755 "$(OCGO_BIN)" "$(INSTALL_DIR)/ocgo$(EXE)"
|
||||||
|
|
||||||
release:
|
release:
|
||||||
@[ -n "$(TAG)" ] || (echo "Usage: make release TAG=v0.1.0" && exit 1)
|
@[ -n "$(TAG)" ] || (echo "Usage: make release TAG=v0.1.0" && exit 1)
|
||||||
|
|
|
||||||
9
build.bat
Normal file
9
build.bat
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
for /f "delims=" %%v in ('git describe --tags --always --dirty 2^>NUL') do set "OCGO_VERSION=%%v"
|
||||||
|
if not defined OCGO_VERSION set "OCGO_VERSION=dev"
|
||||||
|
|
||||||
|
if not exist bin mkdir bin
|
||||||
|
go build -ldflags "-X main.version=%OCGO_VERSION%" -o bin\ocgo.exe .\cmd\ocgo
|
||||||
|
exit /b %ERRORLEVEL%
|
||||||
40
cmd/ocgo/listener_pid_netstat.go
Normal file
40
cmd/ocgo/listener_pid_netstat.go
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseWindowsNetstatPID(output string, port int) (int, error) {
|
||||||
|
wanted := strconv.Itoa(port)
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 5 || !strings.EqualFold(fields[0], "tcp") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(fields[len(fields)-2], "listening") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !netstatAddressUsesPort(fields[1], wanted) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pid, err := strconv.Atoi(fields[len(fields)-1])
|
||||||
|
if err == nil && pid > 0 {
|
||||||
|
return pid, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, errors.New("no listener found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func netstatAddressUsesPort(address, port string) bool {
|
||||||
|
address = strings.TrimSpace(address)
|
||||||
|
if address == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(address, "[") {
|
||||||
|
return strings.HasSuffix(address, "]:"+port)
|
||||||
|
}
|
||||||
|
idx := strings.LastIndex(address, ":")
|
||||||
|
return idx >= 0 && address[idx+1:] == port
|
||||||
|
}
|
||||||
27
cmd/ocgo/listener_pid_unix.go
Normal file
27
cmd/ocgo/listener_pid_unix.go
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func findListenerPID(port int) (int, error) {
|
||||||
|
if port == 0 {
|
||||||
|
return 0, errors.New("missing port")
|
||||||
|
}
|
||||||
|
out, err := exec.Command("lsof", "-nP", "-tiTCP:"+strconv.Itoa(port), "-sTCP:LISTEN").Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
|
pid, err := strconv.Atoi(strings.TrimSpace(line))
|
||||||
|
if err == nil && pid > 0 {
|
||||||
|
return pid, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, errors.New("no listener found")
|
||||||
|
}
|
||||||
19
cmd/ocgo/listener_pid_windows.go
Normal file
19
cmd/ocgo/listener_pid_windows.go
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func findListenerPID(port int) (int, error) {
|
||||||
|
if port == 0 {
|
||||||
|
return 0, errors.New("missing port")
|
||||||
|
}
|
||||||
|
out, err := exec.Command("netstat", "-ano", "-p", "tcp").Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return parseWindowsNetstatPID(string(out), port)
|
||||||
|
}
|
||||||
467
cmd/ocgo/main.go
467
cmd/ocgo/main.go
|
|
@ -32,9 +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"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Model string `json:"model"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnthropicRequest struct {
|
type AnthropicRequest struct {
|
||||||
|
|
@ -60,13 +62,18 @@ type ATool struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type OAIRequest struct {
|
type OAIRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Messages []OAIMessage `json:"messages"`
|
Messages []OAIMessage `json:"messages"`
|
||||||
Stream bool `json:"stream,omitempty"`
|
Stream bool `json:"stream,omitempty"`
|
||||||
MaxTokens int `json:"max_tokens,omitempty"`
|
StreamOptions *OAIStreamOptions `json:"stream_options,omitempty"`
|
||||||
Temperature *float64 `json:"temperature,omitempty"`
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
TopP *float64 `json:"top_p,omitempty"`
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
Tools []OAITool `json:"tools,omitempty"`
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
Tools []OAITool `json:"tools,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OAIStreamOptions struct {
|
||||||
|
IncludeUsage bool `json:"include_usage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResponsesRequest struct {
|
type ResponsesRequest struct {
|
||||||
|
|
@ -142,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,7 +194,39 @@ func listCmd() *cobra.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
func knownModelIDs() []string {
|
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", "deepseek-v4-flash", "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 {
|
||||||
|
model = strings.TrimSuffix(model, "[1M]")
|
||||||
|
model = strings.TrimSuffix(model, "[128K]")
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelContextWindow(model string) int {
|
||||||
|
switch cleanModelName(model) {
|
||||||
|
case "deepseek-v4-pro", "deepseek-v4-flash":
|
||||||
|
return 1000000
|
||||||
|
default:
|
||||||
|
return 128000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelDisplayName(model string) string {
|
||||||
|
if modelContextWindow(model) >= 1000000 {
|
||||||
|
return model + "[1M]"
|
||||||
|
}
|
||||||
|
return model
|
||||||
}
|
}
|
||||||
|
|
||||||
func modelSupportsImages(model string) bool {
|
func modelSupportsImages(model string) bool {
|
||||||
|
|
@ -279,17 +326,22 @@ func launchCmd() *cobra.Command {
|
||||||
|
|
||||||
func serveCmd() *cobra.Command {
|
func serveCmd() *cobra.Command {
|
||||||
var background bool
|
var background bool
|
||||||
|
var model string
|
||||||
cmd := &cobra.Command{Use: "serve", Short: "Start local Anthropic-compatible proxy", RunE: func(cmd *cobra.Command, args []string) error {
|
cmd := &cobra.Command{Use: "serve", Short: "Start local Anthropic-compatible proxy", RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if background {
|
if background {
|
||||||
return startBackground()
|
return startBackground(model)
|
||||||
}
|
}
|
||||||
cfg, err := loadConfig()
|
cfg, err := loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if model != "" {
|
||||||
|
cfg.Model = model
|
||||||
|
}
|
||||||
return runServer(cfg)
|
return runServer(cfg)
|
||||||
}}
|
}}
|
||||||
cmd.Flags().BoolVarP(&background, "background", "b", false, "Run proxy in the background")
|
cmd.Flags().BoolVarP(&background, "background", "b", false, "Run proxy in the background")
|
||||||
|
cmd.Flags().StringVarP(&model, "model", "m", "", "Upstream model ID (default: kimi-k2.6)")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -330,6 +382,10 @@ func statusCmd() *cobra.Command {
|
||||||
fmt.Printf("Proxy is running on %s:%d (PID %d)\n", cfg.Host, cfg.Port, pid)
|
fmt.Printf("Proxy is running on %s:%d (PID %d)\n", cfg.Host, cfg.Port, pid)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if pid, err := findListenerPID(cfg.Port); err == nil {
|
||||||
|
fmt.Printf("Proxy is running on %s:%d (PID %d, discovered from listener)\n", cfg.Host, cfg.Port, pid)
|
||||||
|
return
|
||||||
|
}
|
||||||
fmt.Printf("Proxy is running on %s:%d (no ocgo PID file)\n", cfg.Host, cfg.Port)
|
fmt.Printf("Proxy is running on %s:%d (no ocgo PID file)\n", cfg.Host, cfg.Port)
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
@ -360,20 +416,13 @@ func proxyMessages(w http.ResponseWriter, r *http.Request, cfg Config) {
|
||||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
or := convertRequest(ar)
|
or := convertRequest(ar, cfg.Model)
|
||||||
if err := validateImageSupport(or); err != nil {
|
if err := validateImageSupport(or); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
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
|
||||||
|
|
@ -385,10 +434,10 @@ func proxyMessages(w http.ResponseWriter, r *http.Request, cfg Config) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ar.Stream {
|
if ar.Stream {
|
||||||
streamAnthropic(w, resp.Body, or.Model)
|
streamAnthropic(w, resp.Body, modelDisplayName(or.Model))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeAnthropicResponse(w, resp.Body, or.Model)
|
writeAnthropicResponse(w, resp.Body, modelDisplayName(or.Model))
|
||||||
}
|
}
|
||||||
|
|
||||||
func proxyChatCompletions(w http.ResponseWriter, r *http.Request, cfg Config) {
|
func proxyChatCompletions(w http.ResponseWriter, r *http.Request, cfg Config) {
|
||||||
|
|
@ -406,14 +455,18 @@ func proxyChatCompletions(w http.ResponseWriter, r *http.Request, cfg Config) {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, openAIURL, bytes.NewReader(body))
|
// Apply default model if configured
|
||||||
if err != nil {
|
if cfg.Model != "" {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
dm := cleanModelName(cfg.Model)
|
||||||
return
|
var reqMap map[string]any
|
||||||
|
if json.Unmarshal(body, &reqMap) == nil {
|
||||||
|
if m, _ := reqMap["model"].(string); m == "" || !isKnownModelID(m) {
|
||||||
|
reqMap["model"] = dm
|
||||||
|
body, _ = json.Marshal(reqMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
resp, err := cfg.postWithRetry(r.Context(), openAIURL, "application/json", body)
|
||||||
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
|
||||||
|
|
@ -434,20 +487,13 @@ func proxyResponses(w http.ResponseWriter, r *http.Request, cfg Config) {
|
||||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
or := responsesToChat(rr)
|
or := responsesToChat(rr, cfg.Model)
|
||||||
if err := validateImageSupport(or); err != nil {
|
if err := validateImageSupport(or); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
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
|
||||||
|
|
@ -459,10 +505,10 @@ func proxyResponses(w http.ResponseWriter, r *http.Request, cfg Config) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rr.Stream {
|
if rr.Stream {
|
||||||
streamResponses(w, resp.Body, or.Model)
|
streamResponses(w, resp.Body, modelDisplayName(or.Model))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeResponsesResponse(w, resp.Body, or.Model)
|
writeResponsesResponse(w, resp.Body, modelDisplayName(or.Model))
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyHeaders(dst, src http.Header) {
|
func copyHeaders(dst, src http.Header) {
|
||||||
|
|
@ -478,14 +524,19 @@ func prepareChatBody(body []byte) ([]byte, error) {
|
||||||
if json.Unmarshal(body, &req) != nil {
|
if json.Unmarshal(body, &req) != nil {
|
||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
changed := requestStreamingUsage(req)
|
||||||
model, _ := req["model"].(string)
|
model, _ := req["model"].(string)
|
||||||
if !rawChatBodyHasImages(req) {
|
if clean := cleanModelName(model); clean != model {
|
||||||
return body, nil
|
req["model"] = clean
|
||||||
|
changed = true
|
||||||
|
model = clean
|
||||||
}
|
}
|
||||||
if !modelSupportsImages(model) {
|
if rawChatBodyHasImages(req) {
|
||||||
return nil, unsupportedImageModelError(model)
|
if !modelSupportsImages(model) {
|
||||||
|
return nil, unsupportedImageModelError(model)
|
||||||
|
}
|
||||||
|
changed = stripRawChatImageDetails(req) || changed
|
||||||
}
|
}
|
||||||
changed := stripRawChatImageDetails(req)
|
|
||||||
if !changed {
|
if !changed {
|
||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
@ -496,6 +547,23 @@ func prepareChatBody(body []byte) ([]byte, error) {
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requestStreamingUsage(req map[string]any) bool {
|
||||||
|
streaming, _ := req["stream"].(bool)
|
||||||
|
if !streaming {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
options, ok := req["stream_options"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
options = map[string]any{}
|
||||||
|
req["stream_options"] = options
|
||||||
|
}
|
||||||
|
if enabled, _ := options["include_usage"].(bool); enabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
options["include_usage"] = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func rawChatBodyHasImages(req map[string]any) bool {
|
func rawChatBodyHasImages(req map[string]any) bool {
|
||||||
messages, _ := req["messages"].([]any)
|
messages, _ := req["messages"].([]any)
|
||||||
for _, item := range messages {
|
for _, item := range messages {
|
||||||
|
|
@ -555,12 +623,15 @@ func stripRawChatImageDetails(req map[string]any) bool {
|
||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertRequest(ar AnthropicRequest) OAIRequest {
|
func convertRequest(ar AnthropicRequest, defaultModel string) OAIRequest {
|
||||||
model := ar.Model
|
model := cleanModelName(ar.Model)
|
||||||
if model == "" || strings.HasPrefix(model, "claude-") {
|
if model == "" || !isKnownModelID(model) {
|
||||||
|
model = cleanModelName(defaultModel)
|
||||||
|
}
|
||||||
|
if model == "" {
|
||||||
model = "kimi-k2.6"
|
model = "kimi-k2.6"
|
||||||
}
|
}
|
||||||
out := OAIRequest{Model: model, Stream: ar.Stream, MaxTokens: ar.MaxTokens, Temperature: ar.Temperature, TopP: ar.TopP}
|
out := OAIRequest{Model: model, Stream: ar.Stream, StreamOptions: streamUsageOptions(ar.Stream), MaxTokens: ar.MaxTokens, Temperature: ar.Temperature, TopP: ar.TopP}
|
||||||
if sys := systemText(ar.System); sys != "" {
|
if sys := systemText(ar.System); sys != "" {
|
||||||
out.Messages = append(out.Messages, OAIMessage{Role: "system", Content: sys})
|
out.Messages = append(out.Messages, OAIMessage{Role: "system", Content: sys})
|
||||||
}
|
}
|
||||||
|
|
@ -573,12 +644,15 @@ func convertRequest(ar AnthropicRequest) OAIRequest {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func responsesToChat(rr ResponsesRequest) OAIRequest {
|
func responsesToChat(rr ResponsesRequest, defaultModel string) OAIRequest {
|
||||||
model := rr.Model
|
model := cleanModelName(rr.Model)
|
||||||
|
if model == "" || !isKnownModelID(model) {
|
||||||
|
model = cleanModelName(defaultModel)
|
||||||
|
}
|
||||||
if model == "" {
|
if model == "" {
|
||||||
model = "kimi-k2.6"
|
model = "kimi-k2.6"
|
||||||
}
|
}
|
||||||
out := OAIRequest{Model: model, Stream: rr.Stream, MaxTokens: rr.MaxTokens, Temperature: rr.Temperature, TopP: rr.TopP}
|
out := OAIRequest{Model: model, Stream: rr.Stream, StreamOptions: streamUsageOptions(rr.Stream), MaxTokens: rr.MaxTokens, Temperature: rr.Temperature, TopP: rr.TopP}
|
||||||
if rr.Instructions != "" {
|
if rr.Instructions != "" {
|
||||||
out.Messages = append(out.Messages, OAIMessage{Role: "system", Content: rr.Instructions})
|
out.Messages = append(out.Messages, OAIMessage{Role: "system", Content: rr.Instructions})
|
||||||
}
|
}
|
||||||
|
|
@ -591,6 +665,13 @@ func responsesToChat(rr ResponsesRequest) OAIRequest {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func streamUsageOptions(streaming bool) *OAIStreamOptions {
|
||||||
|
if !streaming {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &OAIStreamOptions{IncludeUsage: true}
|
||||||
|
}
|
||||||
|
|
||||||
func requestHasImages(or OAIRequest) bool {
|
func requestHasImages(or OAIRequest) bool {
|
||||||
for _, m := range or.Messages {
|
for _, m := range or.Messages {
|
||||||
if contentHasImage(m.Content) {
|
if contentHasImage(m.Content) {
|
||||||
|
|
@ -923,6 +1004,98 @@ func blockText(raw json.RawMessage) string {
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type tokenUsage struct {
|
||||||
|
InputTokens int
|
||||||
|
OutputTokens int
|
||||||
|
TotalTokens int
|
||||||
|
CachedInputTokens int
|
||||||
|
Present bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageFromJSON(raw json.RawMessage) tokenUsage {
|
||||||
|
var fields map[string]any
|
||||||
|
if len(raw) == 0 || json.Unmarshal(raw, &fields) != nil {
|
||||||
|
return tokenUsage{}
|
||||||
|
}
|
||||||
|
return usageFromFields(fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageFromFields(fields map[string]any) tokenUsage {
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return tokenUsage{}
|
||||||
|
}
|
||||||
|
u := tokenUsage{Present: true}
|
||||||
|
u.InputTokens = intField(fields, "prompt_tokens")
|
||||||
|
if u.InputTokens == 0 {
|
||||||
|
u.InputTokens = intField(fields, "input_tokens")
|
||||||
|
}
|
||||||
|
u.OutputTokens = intField(fields, "completion_tokens")
|
||||||
|
if u.OutputTokens == 0 {
|
||||||
|
u.OutputTokens = intField(fields, "output_tokens")
|
||||||
|
}
|
||||||
|
u.TotalTokens = intField(fields, "total_tokens")
|
||||||
|
if u.TotalTokens == 0 && (u.InputTokens > 0 || u.OutputTokens > 0) {
|
||||||
|
u.TotalTokens = u.InputTokens + u.OutputTokens
|
||||||
|
}
|
||||||
|
u.CachedInputTokens = cachedTokens(fields)
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func intField(fields map[string]any, name string) int {
|
||||||
|
v, ok := fields[name]
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch n := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return int(n)
|
||||||
|
case int:
|
||||||
|
return n
|
||||||
|
case json.Number:
|
||||||
|
i, _ := n.Int64()
|
||||||
|
return int(i)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func cachedTokens(fields map[string]any) int {
|
||||||
|
for _, key := range []string{"prompt_tokens_details", "input_tokens_details"} {
|
||||||
|
if nested, ok := fields[key].(map[string]any); ok {
|
||||||
|
if n := intField(nested, "cached_tokens"); n > 0 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return intField(fields, "cached_tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
func anthropicUsage(u tokenUsage) map[string]int {
|
||||||
|
usage := map[string]int{"input_tokens": u.InputTokens, "output_tokens": u.OutputTokens}
|
||||||
|
if u.CachedInputTokens > 0 {
|
||||||
|
usage["cache_read_input_tokens"] = u.CachedInputTokens
|
||||||
|
}
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
func anthropicDeltaUsage(u tokenUsage) map[string]int {
|
||||||
|
usage := map[string]int{"output_tokens": u.OutputTokens}
|
||||||
|
if u.InputTokens > 0 {
|
||||||
|
usage["input_tokens"] = u.InputTokens
|
||||||
|
}
|
||||||
|
if u.CachedInputTokens > 0 {
|
||||||
|
usage["cache_read_input_tokens"] = u.CachedInputTokens
|
||||||
|
}
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesUsage(u tokenUsage) map[string]any {
|
||||||
|
usage := map[string]any{"input_tokens": u.InputTokens, "output_tokens": u.OutputTokens, "total_tokens": u.TotalTokens}
|
||||||
|
if u.CachedInputTokens > 0 {
|
||||||
|
usage["input_tokens_details"] = map[string]int{"cached_tokens": u.CachedInputTokens}
|
||||||
|
}
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
func streamAnthropic(w http.ResponseWriter, body io.Reader, model string) {
|
func streamAnthropic(w http.ResponseWriter, body io.Reader, model string) {
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
flusher, _ := w.(http.Flusher)
|
flusher, _ := w.(http.Flusher)
|
||||||
|
|
@ -936,6 +1109,7 @@ func streamAnthropic(w http.ResponseWriter, body io.Reader, model string) {
|
||||||
toolIndexes := map[int]int{}
|
toolIndexes := map[int]int{}
|
||||||
var tools []streamedResponseToolCall
|
var tools []streamedResponseToolCall
|
||||||
var reasoning strings.Builder
|
var reasoning strings.Builder
|
||||||
|
usage := tokenUsage{}
|
||||||
s := bufio.NewScanner(body)
|
s := bufio.NewScanner(body)
|
||||||
for s.Scan() {
|
for s.Scan() {
|
||||||
line := strings.TrimSpace(s.Text())
|
line := strings.TrimSpace(s.Text())
|
||||||
|
|
@ -947,6 +1121,9 @@ func streamAnthropic(w http.ResponseWriter, body io.Reader, model string) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
chunk := parseOpenAIStreamChunk([]byte(data))
|
chunk := parseOpenAIStreamChunk([]byte(data))
|
||||||
|
if chunk.Usage.Present {
|
||||||
|
usage = chunk.Usage
|
||||||
|
}
|
||||||
if chunk.ReasoningContent != "" {
|
if chunk.ReasoningContent != "" {
|
||||||
reasoning.WriteString(chunk.ReasoningContent)
|
reasoning.WriteString(chunk.ReasoningContent)
|
||||||
}
|
}
|
||||||
|
|
@ -1010,7 +1187,8 @@ func streamAnthropic(w http.ResponseWriter, body io.Reader, model string) {
|
||||||
if len(tools) > 0 {
|
if len(tools) > 0 {
|
||||||
stopReason = "tool_use"
|
stopReason = "tool_use"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":%q,\"stop_sequence\":null},\"usage\":{\"output_tokens\":0}}\n\n", stopReason)
|
usageJSON, _ := json.Marshal(anthropicDeltaUsage(usage))
|
||||||
|
fmt.Fprintf(w, "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":%q,\"stop_sequence\":null},\"usage\":%s}\n\n", stopReason, usageJSON)
|
||||||
fmt.Fprint(w, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
|
fmt.Fprint(w, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1036,6 +1214,7 @@ func writeAnthropicResponse(w http.ResponseWriter, body io.Reader, model string)
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
} `json:"message"`
|
} `json:"message"`
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
|
Usage json.RawMessage `json:"usage"`
|
||||||
}
|
}
|
||||||
_ = json.NewDecoder(body).Decode(&v)
|
_ = json.NewDecoder(body).Decode(&v)
|
||||||
text := ""
|
text := ""
|
||||||
|
|
@ -1043,7 +1222,7 @@ func writeAnthropicResponse(w http.ResponseWriter, body io.Reader, model string)
|
||||||
text = v.Choices[0].Message.Content
|
text = v.Choices[0].Message.Content
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": "ocgo", "type": "message", "role": "assistant", "model": model, "content": []map[string]string{{"type": "text", "text": text}}, "stop_reason": "end_turn", "usage": map[string]int{"input_tokens": 0, "output_tokens": 0}})
|
_ = json.NewEncoder(w).Encode(map[string]any{"id": "ocgo", "type": "message", "role": "assistant", "model": model, "content": []map[string]string{{"type": "text", "text": text}}, "stop_reason": "end_turn", "usage": anthropicUsage(usageFromJSON(v.Usage))})
|
||||||
}
|
}
|
||||||
|
|
||||||
func streamResponses(w http.ResponseWriter, body io.Reader, model string) {
|
func streamResponses(w http.ResponseWriter, body io.Reader, model string) {
|
||||||
|
|
@ -1060,6 +1239,7 @@ func streamResponses(w http.ResponseWriter, body io.Reader, model string) {
|
||||||
nextOutputIndex := 0
|
nextOutputIndex := 0
|
||||||
var text strings.Builder
|
var text strings.Builder
|
||||||
var reasoning strings.Builder
|
var reasoning strings.Builder
|
||||||
|
usage := tokenUsage{}
|
||||||
toolIndexes := map[int]int{}
|
toolIndexes := map[int]int{}
|
||||||
var tools []streamedResponseToolCall
|
var tools []streamedResponseToolCall
|
||||||
s := bufio.NewScanner(body)
|
s := bufio.NewScanner(body)
|
||||||
|
|
@ -1073,6 +1253,9 @@ func streamResponses(w http.ResponseWriter, body io.Reader, model string) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
chunk := parseOpenAIStreamChunk([]byte(data))
|
chunk := parseOpenAIStreamChunk([]byte(data))
|
||||||
|
if chunk.Usage.Present {
|
||||||
|
usage = chunk.Usage
|
||||||
|
}
|
||||||
if chunk.ReasoningContent != "" {
|
if chunk.ReasoningContent != "" {
|
||||||
reasoning.WriteString(chunk.ReasoningContent)
|
reasoning.WriteString(chunk.ReasoningContent)
|
||||||
}
|
}
|
||||||
|
|
@ -1140,7 +1323,7 @@ func streamResponses(w http.ResponseWriter, body io.Reader, model string) {
|
||||||
writeResponseEvent(w, "response.output_item.done", map[string]any{"type": "response.output_item.done", "output_index": tool.OutputIndex, "item": item})
|
writeResponseEvent(w, "response.output_item.done", map[string]any{"type": "response.output_item.done", "output_index": tool.OutputIndex, "item": item})
|
||||||
output = append(output, item)
|
output = append(output, item)
|
||||||
}
|
}
|
||||||
writeResponseEvent(w, "response.completed", map[string]any{"type": "response.completed", "response": map[string]any{"id": id, "object": "response", "model": model, "status": "completed", "output": output}})
|
writeResponseEvent(w, "response.completed", map[string]any{"type": "response.completed", "response": map[string]any{"id": id, "object": "response", "model": model, "status": "completed", "output": output, "usage": responsesUsage(usage)}})
|
||||||
}
|
}
|
||||||
|
|
||||||
type streamedResponseToolCall struct {
|
type streamedResponseToolCall struct {
|
||||||
|
|
@ -1159,6 +1342,7 @@ type openAIStreamChunk struct {
|
||||||
Content string
|
Content string
|
||||||
ReasoningContent string
|
ReasoningContent string
|
||||||
ToolCalls []openAIStreamToolCall
|
ToolCalls []openAIStreamToolCall
|
||||||
|
Usage tokenUsage
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseOpenAIStreamChunk(data []byte) openAIStreamChunk {
|
func parseOpenAIStreamChunk(data []byte) openAIStreamChunk {
|
||||||
|
|
@ -1177,13 +1361,16 @@ func parseOpenAIStreamChunk(data []byte) openAIStreamChunk {
|
||||||
} `json:"tool_calls"`
|
} `json:"tool_calls"`
|
||||||
} `json:"delta"`
|
} `json:"delta"`
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
|
Usage json.RawMessage `json:"usage"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(data, &v)
|
_ = json.Unmarshal(data, &v)
|
||||||
|
out := openAIStreamChunk{Usage: usageFromJSON(v.Usage)}
|
||||||
if len(v.Choices) == 0 {
|
if len(v.Choices) == 0 {
|
||||||
return openAIStreamChunk{}
|
return out
|
||||||
}
|
}
|
||||||
delta := v.Choices[0].Delta
|
delta := v.Choices[0].Delta
|
||||||
out := openAIStreamChunk{Content: delta.Content, ReasoningContent: delta.ReasoningContent}
|
out.Content = delta.Content
|
||||||
|
out.ReasoningContent = delta.ReasoningContent
|
||||||
for _, tc := range delta.ToolCalls {
|
for _, tc := range delta.ToolCalls {
|
||||||
out.ToolCalls = append(out.ToolCalls, openAIStreamToolCall{Index: tc.Index, ID: tc.ID, Name: tc.Function.Name, Arguments: tc.Function.Arguments})
|
out.ToolCalls = append(out.ToolCalls, openAIStreamToolCall{Index: tc.Index, ID: tc.ID, Name: tc.Function.Name, Arguments: tc.Function.Arguments})
|
||||||
}
|
}
|
||||||
|
|
@ -1204,6 +1391,7 @@ func writeResponsesResponse(w http.ResponseWriter, body io.Reader, model string)
|
||||||
ToolCalls []OAIToolCall `json:"tool_calls"`
|
ToolCalls []OAIToolCall `json:"tool_calls"`
|
||||||
} `json:"message"`
|
} `json:"message"`
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
|
Usage json.RawMessage `json:"usage"`
|
||||||
}
|
}
|
||||||
_ = json.NewDecoder(body).Decode(&v)
|
_ = json.NewDecoder(body).Decode(&v)
|
||||||
text := ""
|
text := ""
|
||||||
|
|
@ -1221,7 +1409,7 @@ func writeResponsesResponse(w http.ResponseWriter, body io.Reader, model string)
|
||||||
output = append(output, map[string]any{"id": "msg_ocgo", "type": "message", "role": "assistant", "content": []map[string]string{{"type": "output_text", "text": text}}})
|
output = append(output, map[string]any{"id": "msg_ocgo", "type": "message", "role": "assistant", "content": []map[string]string{{"type": "output_text", "text": text}}})
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": "resp_ocgo", "object": "response", "created_at": time.Now().Unix(), "model": model, "status": "completed", "output": output, "usage": map[string]int{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}})
|
_ = json.NewEncoder(w).Encode(map[string]any{"id": "resp_ocgo", "object": "response", "created_at": time.Now().Unix(), "model": model, "status": "completed", "output": output, "usage": responsesUsage(usageFromJSON(v.Usage))})
|
||||||
}
|
}
|
||||||
|
|
||||||
func countTokens(w http.ResponseWriter, r *http.Request) {
|
func countTokens(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -1232,7 +1420,7 @@ func ensureServer(base string) error {
|
||||||
if healthy(base) {
|
if healthy(base) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := startBackground(); err != nil {
|
if err := startBackground(""); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
|
@ -1250,7 +1438,7 @@ func startLaunchServer(base string) (*exec.Cmd, error) {
|
||||||
if healthy(base) {
|
if healthy(base) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
cmd, err := startServerProcess(false)
|
cmd, err := startServerProcess(false, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -1285,12 +1473,12 @@ func healthy(base string) bool {
|
||||||
return resp.StatusCode == 200
|
return resp.StatusCode == 200
|
||||||
}
|
}
|
||||||
|
|
||||||
func startBackground() error {
|
func startBackground(model string) error {
|
||||||
_, err := startServerProcess(true)
|
_, err := startServerProcess(true, model)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func startServerProcess(detached bool) (*exec.Cmd, error) {
|
func startServerProcess(detached bool, model string) (*exec.Cmd, error) {
|
||||||
bin, err := os.Executable()
|
bin, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -1299,6 +1487,9 @@ func startServerProcess(detached bool) (*exec.Cmd, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
args := []string{"serve"}
|
args := []string{"serve"}
|
||||||
|
if model != "" {
|
||||||
|
args = append(args, "--model", model)
|
||||||
|
}
|
||||||
cmd := exec.Command(bin, args...)
|
cmd := exec.Command(bin, args...)
|
||||||
logf, err := os.OpenFile(filepath.Join(configDir(), "ocgo.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
logf, err := os.OpenFile(filepath.Join(configDir(), "ocgo.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1319,6 +1510,76 @@ func startServerProcess(detached bool) (*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()
|
||||||
|
|
@ -1382,6 +1643,7 @@ func writeCodexProfile(path, baseURL string) error {
|
||||||
func writeCodexModelCatalog(path string) error {
|
func writeCodexModelCatalog(path string) error {
|
||||||
models := make([]map[string]any, 0, len(knownModelIDs()))
|
models := make([]map[string]any, 0, len(knownModelIDs()))
|
||||||
for i, id := range knownModelIDs() {
|
for i, id := range knownModelIDs() {
|
||||||
|
cw := modelContextWindow(id)
|
||||||
models = append(models, map[string]any{
|
models = append(models, map[string]any{
|
||||||
"slug": id,
|
"slug": id,
|
||||||
"display_name": id,
|
"display_name": id,
|
||||||
|
|
@ -1404,8 +1666,8 @@ func writeCodexModelCatalog(path string) error {
|
||||||
"truncation_policy": map[string]any{"mode": "tokens", "limit": 10000},
|
"truncation_policy": map[string]any{"mode": "tokens", "limit": 10000},
|
||||||
"supports_parallel_tool_calls": false,
|
"supports_parallel_tool_calls": false,
|
||||||
"supports_image_detail_original": false,
|
"supports_image_detail_original": false,
|
||||||
"context_window": 128000,
|
"context_window": cw,
|
||||||
"max_context_window": 128000,
|
"max_context_window": cw,
|
||||||
"auto_compact_token_limit": nil,
|
"auto_compact_token_limit": nil,
|
||||||
"effective_context_window_percent": 95,
|
"effective_context_window_percent": 95,
|
||||||
"experimental_supported_tools": []any{},
|
"experimental_supported_tools": []any{},
|
||||||
|
|
@ -1470,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
|
||||||
}
|
}
|
||||||
|
|
@ -1482,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 == "" {
|
||||||
|
|
@ -1499,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 {
|
||||||
|
|
@ -1508,24 +1796,3 @@ func readPID() (int, error) {
|
||||||
_, err = fmt.Sscan(string(b), &pid)
|
_, err = fmt.Sscan(string(b), &pid)
|
||||||
return pid, err
|
return pid, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func findListenerPID(port int) (int, error) {
|
|
||||||
if port == 0 {
|
|
||||||
return 0, errors.New("missing port")
|
|
||||||
}
|
|
||||||
out, err := exec.Command("lsof", "-nP", "-tiTCP:"+strconv.Itoa(port), "-sTCP:LISTEN").Output()
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pid, err := strconv.Atoi(line)
|
|
||||||
if err == nil && pid > 0 {
|
|
||||||
return pid, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, errors.New("no listener found")
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -59,6 +60,36 @@ func TestWriteCodexProfileReplacesExistingSections(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModelContextWindow(t *testing.T) {
|
||||||
|
if got := modelContextWindow("deepseek-v4-flash[1M]"); got != 1000000 {
|
||||||
|
t.Fatalf("deepseek-v4-flash[1M]: got %d, want 1000000", got)
|
||||||
|
}
|
||||||
|
if got := modelContextWindow("deepseek-v4-pro[1M]"); got != 1000000 {
|
||||||
|
t.Fatalf("deepseek-v4-pro[1M]: got %d, want 1000000", got)
|
||||||
|
}
|
||||||
|
if got := modelContextWindow("kimi-k2.6"); got != 128000 {
|
||||||
|
t.Fatalf("kimi-k2.6: got %d, want 128000", got)
|
||||||
|
}
|
||||||
|
if got := modelContextWindow("unknown-model"); got != 128000 {
|
||||||
|
t.Fatalf("unknown-model: got %d, want 128000", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanModelName(t *testing.T) {
|
||||||
|
tests := []struct{ in, want string }{
|
||||||
|
{"deepseek-v4-flash[1M]", "deepseek-v4-flash"},
|
||||||
|
{"deepseek-v4-pro[1M]", "deepseek-v4-pro"},
|
||||||
|
{"deepseek-v4-flash", "deepseek-v4-flash"},
|
||||||
|
{"kimi-k2.6", "kimi-k2.6"},
|
||||||
|
{"kimi-k2.6[128K]", "kimi-k2.6"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := cleanModelName(tt.in); got != tt.want {
|
||||||
|
t.Errorf("cleanModelName(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWriteCodexModelCatalog(t *testing.T) {
|
func TestWriteCodexModelCatalog(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "ocgo-models.json")
|
path := filepath.Join(t.TempDir(), "ocgo-models.json")
|
||||||
if err := writeCodexModelCatalog(path); err != nil {
|
if err := writeCodexModelCatalog(path); err != nil {
|
||||||
|
|
@ -69,7 +100,7 @@ func TestWriteCodexModelCatalog(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
content := string(b)
|
content := string(b)
|
||||||
for _, want := range []string{`"models"`, `"slug": "deepseek-v4-pro"`, `"context_window": 128000`, `"truncation_policy"`, `"supports_image_detail_original": false`, `"image"`} {
|
for _, want := range []string{`"models"`, `"slug": "deepseek-v4-pro[1M]"`, `"context_window": 1000000`, `"truncation_policy"`, `"supports_image_detail_original": false`, `"image"`} {
|
||||||
if !strings.Contains(content, want) {
|
if !strings.Contains(content, want) {
|
||||||
t.Fatalf("missing %q in:\n%s", want, content)
|
t.Fatalf("missing %q in:\n%s", want, content)
|
||||||
}
|
}
|
||||||
|
|
@ -186,7 +217,7 @@ func TestResponsesInputPreservesImages(t *testing.T) {
|
||||||
|
|
||||||
func TestResponsesImageKeepsKimiModel(t *testing.T) {
|
func TestResponsesImageKeepsKimiModel(t *testing.T) {
|
||||||
req := ResponsesRequest{Model: "kimi-k2.6", Input: []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":"describe this"},{"type":"input_image","image_url":"data:image/png;base64,abc"}]}]`)}
|
req := ResponsesRequest{Model: "kimi-k2.6", Input: []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":"describe this"},{"type":"input_image","image_url":"data:image/png;base64,abc"}]}]`)}
|
||||||
out := responsesToChat(req)
|
out := responsesToChat(req, "")
|
||||||
if out.Model != "kimi-k2.6" {
|
if out.Model != "kimi-k2.6" {
|
||||||
t.Fatalf("image request should keep Kimi model, got %q", out.Model)
|
t.Fatalf("image request should keep Kimi model, got %q", out.Model)
|
||||||
}
|
}
|
||||||
|
|
@ -197,7 +228,7 @@ func TestResponsesImageKeepsKimiModel(t *testing.T) {
|
||||||
|
|
||||||
func TestResponsesImageRejectsUnsupportedModel(t *testing.T) {
|
func TestResponsesImageRejectsUnsupportedModel(t *testing.T) {
|
||||||
req := ResponsesRequest{Model: "deepseek-v4-pro", Input: []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":"describe this"},{"type":"input_image","image_url":"data:image/png;base64,abc"}]}]`)}
|
req := ResponsesRequest{Model: "deepseek-v4-pro", Input: []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":"describe this"},{"type":"input_image","image_url":"data:image/png;base64,abc"}]}]`)}
|
||||||
out := responsesToChat(req)
|
out := responsesToChat(req, "")
|
||||||
if err := validateImageSupport(out); err == nil || !strings.Contains(err.Error(), "deepseek-v4-pro") {
|
if err := validateImageSupport(out); err == nil || !strings.Contains(err.Error(), "deepseek-v4-pro") {
|
||||||
t.Fatalf("DeepSeek image request should be rejected, got %v", err)
|
t.Fatalf("DeepSeek image request should be rejected, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -223,6 +254,39 @@ func TestRawChatImageRejectsUnsupportedModel(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRawChatStreamRequestsUsage(t *testing.T) {
|
||||||
|
body, err := prepareChatBody([]byte(`{"model":"kimi-k2.6","stream":true,"stream_options":{"foo":"bar"},"messages":[{"role":"user","content":"hello"}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var req map[string]any
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
options, ok := req["stream_options"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing stream options in %s", string(body))
|
||||||
|
}
|
||||||
|
if options["include_usage"] != true || options["foo"] != "bar" {
|
||||||
|
t.Fatalf("bad stream options: %+v", options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertedStreamingRequestsAskForUsage(t *testing.T) {
|
||||||
|
anthropic := convertRequest(AnthropicRequest{Model: "kimi-k2.6", Stream: true, Messages: []AMessage{{Role: "user", Content: []byte(`hello`)}}}, "")
|
||||||
|
if anthropic.StreamOptions == nil || !anthropic.StreamOptions.IncludeUsage {
|
||||||
|
t.Fatalf("anthropic conversion should request stream usage: %+v", anthropic.StreamOptions)
|
||||||
|
}
|
||||||
|
responses := responsesToChat(ResponsesRequest{Model: "kimi-k2.6", Stream: true, Input: []byte(`"hello"`)}, "")
|
||||||
|
if responses.StreamOptions == nil || !responses.StreamOptions.IncludeUsage {
|
||||||
|
t.Fatalf("responses conversion should request stream usage: %+v", responses.StreamOptions)
|
||||||
|
}
|
||||||
|
plain := responsesToChat(ResponsesRequest{Model: "kimi-k2.6", Input: []byte(`"hello"`)}, "")
|
||||||
|
if plain.StreamOptions != nil {
|
||||||
|
t.Fatalf("non-streaming conversion should not set stream options: %+v", plain.StreamOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAnthropicContentPreservesImages(t *testing.T) {
|
func TestAnthropicContentPreservesImages(t *testing.T) {
|
||||||
messages := contentToOpenAI(AMessage{Role: "user", Content: []byte(`[{"type":"text","text":"what is this?"},{"type":"image","source":{"type":"base64","media_type":"image/jpeg","data":"abc"}}]`)})
|
messages := contentToOpenAI(AMessage{Role: "user", Content: []byte(`[{"type":"text","text":"what is this?"},{"type":"image","source":{"type":"base64","media_type":"image/jpeg","data":"abc"}}]`)})
|
||||||
if len(messages) != 1 {
|
if len(messages) != 1 {
|
||||||
|
|
@ -241,7 +305,7 @@ func TestAnthropicContentPreservesImages(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnthropicImageKeepsKimiModel(t *testing.T) {
|
func TestAnthropicImageKeepsKimiModel(t *testing.T) {
|
||||||
out := convertRequest(AnthropicRequest{Model: "kimi-k2.6", Messages: []AMessage{{Role: "user", Content: []byte(`[{"type":"text","text":"what is this?"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"abc"}}]`)}}})
|
out := convertRequest(AnthropicRequest{Model: "kimi-k2.6", Messages: []AMessage{{Role: "user", Content: []byte(`[{"type":"text","text":"what is this?"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"abc"}}]`)}}}, "")
|
||||||
if out.Model != "kimi-k2.6" {
|
if out.Model != "kimi-k2.6" {
|
||||||
t.Fatalf("image request should keep Kimi model, got %q", out.Model)
|
t.Fatalf("image request should keep Kimi model, got %q", out.Model)
|
||||||
}
|
}
|
||||||
|
|
@ -251,7 +315,7 @@ func TestAnthropicImageKeepsKimiModel(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnthropicImageRejectsUnsupportedModel(t *testing.T) {
|
func TestAnthropicImageRejectsUnsupportedModel(t *testing.T) {
|
||||||
out := convertRequest(AnthropicRequest{Model: "deepseek-v4-pro", Messages: []AMessage{{Role: "user", Content: []byte(`[{"type":"text","text":"what is this?"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"abc"}}]`)}}})
|
out := convertRequest(AnthropicRequest{Model: "deepseek-v4-pro", Messages: []AMessage{{Role: "user", Content: []byte(`[{"type":"text","text":"what is this?"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"abc"}}]`)}}}, "")
|
||||||
if err := validateImageSupport(out); err == nil || !strings.Contains(err.Error(), "deepseek-v4-pro") {
|
if err := validateImageSupport(out); err == nil || !strings.Contains(err.Error(), "deepseek-v4-pro") {
|
||||||
t.Fatalf("DeepSeek image request should be rejected, got %v", err)
|
t.Fatalf("DeepSeek image request should be rejected, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -262,6 +326,115 @@ func contentString(v any) string {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseWindowsNetstatPID(t *testing.T) {
|
||||||
|
output := strings.Join([]string{
|
||||||
|
"Proto Local Address Foreign Address State PID",
|
||||||
|
"TCP 127.0.0.1:3456 0.0.0.0:0 LISTENING 4321",
|
||||||
|
"TCP [::1]:9999 [::]:0 LISTENING 8765",
|
||||||
|
"TCP 127.0.0.1:34560 0.0.0.0:0 LISTENING 1111",
|
||||||
|
}, "\n")
|
||||||
|
pid, err := parseWindowsNetstatPID(output, 3456)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if pid != 4321 {
|
||||||
|
t.Fatalf("pid = %d, want 4321", pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseWindowsNetstatPIDMatchesIPv6(t *testing.T) {
|
||||||
|
output := "TCP [::]:3456 [::]:0 LISTENING 2468\n"
|
||||||
|
pid, err := parseWindowsNetstatPID(output, 3456)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if pid != 2468 {
|
||||||
|
t.Fatalf("pid = %d, want 2468", pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteAnthropicResponseIncludesUsage(t *testing.T) {
|
||||||
|
body := strings.NewReader(`{"choices":[{"message":{"content":"done"}}],"usage":{"prompt_tokens":11,"completion_tokens":5,"total_tokens":16,"prompt_tokens_details":{"cached_tokens":4}}}`)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
writeAnthropicResponse(w, body, "kimi-k2.6")
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
usage, ok := out["usage"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing usage: %+v", out)
|
||||||
|
}
|
||||||
|
if usage["input_tokens"] != float64(11) || usage["output_tokens"] != float64(5) || usage["cache_read_input_tokens"] != float64(4) {
|
||||||
|
t.Fatalf("bad anthropic usage: %+v", usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteResponsesResponseIncludesUsage(t *testing.T) {
|
||||||
|
body := strings.NewReader(`{"choices":[{"message":{"content":"done"}}],"usage":{"prompt_tokens":7,"completion_tokens":3,"total_tokens":10,"input_tokens_details":{"cached_tokens":2}}}`)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
writeResponsesResponse(w, body, "kimi-k2.6")
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
usage, ok := out["usage"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing usage: %+v", out)
|
||||||
|
}
|
||||||
|
if usage["input_tokens"] != float64(7) || usage["output_tokens"] != float64(3) || usage["total_tokens"] != float64(10) {
|
||||||
|
t.Fatalf("bad responses usage: %+v", usage)
|
||||||
|
}
|
||||||
|
details, ok := usage["input_tokens_details"].(map[string]any)
|
||||||
|
if !ok || details["cached_tokens"] != float64(2) {
|
||||||
|
t.Fatalf("bad cached details: %+v", usage["input_tokens_details"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseOpenAIStreamChunkReadsUsageOnly(t *testing.T) {
|
||||||
|
chunk := parseOpenAIStreamChunk([]byte(`{"choices":[],"usage":{"prompt_tokens":8,"completion_tokens":4,"total_tokens":12}}`))
|
||||||
|
if !chunk.Usage.Present || chunk.Usage.InputTokens != 8 || chunk.Usage.OutputTokens != 4 || chunk.Usage.TotalTokens != 12 {
|
||||||
|
t.Fatalf("bad stream usage: %+v", chunk.Usage)
|
||||||
|
}
|
||||||
|
if chunk.Content != "" || len(chunk.ToolCalls) != 0 {
|
||||||
|
t.Fatalf("usage-only chunk should not include deltas: %+v", chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamAnthropicIncludesFinalUsage(t *testing.T) {
|
||||||
|
body := strings.NewReader(strings.Join([]string{
|
||||||
|
`data: {"choices":[{"delta":{"content":"hi"}}]}`,
|
||||||
|
`data: {"choices":[],"usage":{"prompt_tokens":7,"completion_tokens":3,"total_tokens":10}}`,
|
||||||
|
`data: [DONE]`,
|
||||||
|
``,
|
||||||
|
}, "\n\n"))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
streamAnthropic(w, body, "kimi-k2.6")
|
||||||
|
out := w.Body.String()
|
||||||
|
for _, want := range []string{`"input_tokens":7`, `"output_tokens":3`} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("missing %q in:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamResponsesIncludesCompletedUsage(t *testing.T) {
|
||||||
|
body := strings.NewReader(strings.Join([]string{
|
||||||
|
`data: {"choices":[{"delta":{"content":"hi"}}]}`,
|
||||||
|
`data: {"choices":[],"usage":{"prompt_tokens":7,"completion_tokens":3,"total_tokens":10}}`,
|
||||||
|
`data: [DONE]`,
|
||||||
|
``,
|
||||||
|
}, "\n\n"))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
streamResponses(w, body, "kimi-k2.6")
|
||||||
|
out := w.Body.String()
|
||||||
|
for _, want := range []string{`event: response.completed`, `"input_tokens":7`, `"output_tokens":3`, `"total_tokens":10`} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("missing %q in:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStreamAnthropicForwardsToolCalls(t *testing.T) {
|
func TestStreamAnthropicForwardsToolCalls(t *testing.T) {
|
||||||
reasoningContentCache.Lock()
|
reasoningContentCache.Lock()
|
||||||
reasoningContentCache.byCallID = map[string]string{}
|
reasoningContentCache.byCallID = map[string]string{}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue