462 lines
14 KiB
Go
462 lines
14 KiB
Go
package provider
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/yetone/magpie/internal/catalog"
|
|
"github.com/yetone/magpie/internal/codexcat"
|
|
"github.com/yetone/magpie/internal/proc"
|
|
)
|
|
|
|
// A ChatGPT account's requests are made the way Codex CLI makes them, as
|
|
// Alma's Codex plugin does: the backend serves Codex's clients, and gates
|
|
// its newest models on a User-Agent that names one — without it
|
|
// /codex/responses answers 404 "Model not found" for a model its own
|
|
// /codex/models lists.
|
|
|
|
// codexSign authenticates a request to the ChatGPT backend with the
|
|
// tokens token hands out, and says it comes from Codex CLI.
|
|
func codexSign(token func(context.Context) (tok, accountID string, err error)) func(context.Context, *http.Request, []byte) error {
|
|
return func(ctx context.Context, req *http.Request, body []byte) error {
|
|
tok, accountID, err := token(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|
if accountID != "" {
|
|
req.Header.Set("chatgpt-account-id", accountID)
|
|
}
|
|
req.Header.Set("OpenAI-Beta", "responses=experimental")
|
|
req.Header.Set("originator", "codex_cli_rs")
|
|
// the version and the User-Agent name one Codex, never older than
|
|
// the client whose request this is (its version header, relayed)
|
|
ver := newerVersion(codexVersion(), req.Header.Get("version"))
|
|
req.Header.Set("version", ver)
|
|
req.Header.Set("User-Agent", codexUserAgent(ver))
|
|
if body == nil {
|
|
return nil
|
|
}
|
|
req.Header.Set("Accept", "text/event-stream")
|
|
var v struct {
|
|
Key string `json:"prompt_cache_key"`
|
|
}
|
|
json.Unmarshal(body, &v)
|
|
if v.Key != "" {
|
|
req.Header.Set("session_id", v.Key)
|
|
req.Header.Set("conversation_id", v.Key)
|
|
} else {
|
|
req.Header.Del("session_id")
|
|
req.Header.Del("conversation_id")
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var codexUA struct {
|
|
sync.Once
|
|
os string
|
|
}
|
|
|
|
// codexUserAgent is the User-Agent Codex CLI of version v sends:
|
|
// "codex_cli_rs/0.155.1 (Mac OS 26.6.0; arm64) Apple_Terminal/455".
|
|
func codexUserAgent(v string) string {
|
|
codexUA.Do(func() { codexUA.os = codexOS() })
|
|
arch := map[string]string{"arm64": "arm64", "amd64": "x86_64"}[runtime.GOARCH]
|
|
if arch == "" {
|
|
arch = runtime.GOARCH
|
|
}
|
|
term := map[string]string{"darwin": "Apple_Terminal/455", "windows": "WindowsTerminal"}[runtime.GOOS]
|
|
if term == "" {
|
|
term = "xterm-256color"
|
|
}
|
|
return "codex_cli_rs/" + v + " (" + codexUA.os + "; " + arch + ") " + term
|
|
}
|
|
|
|
var versionRE = regexp.MustCompile(`\d+(\.\d+)+`)
|
|
|
|
// codexOS names the system as Codex CLI does: "Mac OS 26.6.0",
|
|
// "Windows 10.0.26100", "Linux 6.8.0".
|
|
func codexOS() string {
|
|
run := func(name string, args ...string) string {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
out, _ := proc.CommandContext(ctx, name, args...).Output()
|
|
return versionRE.FindString(string(out))
|
|
}
|
|
three := func(v string) string {
|
|
for strings.Count(v, ".") < 2 {
|
|
v += ".0"
|
|
}
|
|
return v
|
|
}
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
if v := run("sw_vers", "-productVersion"); v != "" {
|
|
return "Mac OS " + three(v)
|
|
}
|
|
return "Mac OS"
|
|
case "windows":
|
|
if v := run("cmd", "/c", "ver"); v != "" { // "Microsoft Windows [Version 10.0.26100.4652]"
|
|
if parts := strings.Split(v, "."); len(parts) > 3 {
|
|
v = strings.Join(parts[:3], ".")
|
|
}
|
|
return "Windows " + v
|
|
}
|
|
return "Windows"
|
|
}
|
|
if v := run("uname", "-r"); v != "" {
|
|
return "Linux " + three(v)
|
|
}
|
|
return "Linux"
|
|
}
|
|
|
|
// codexPromptsPath is where the instructions of the account's models are
|
|
// kept, next to its model list, for every magpie process to find.
|
|
func codexPromptsPath() string {
|
|
return filepath.Join(filepath.Dir(catalog.LivePath("codex")), "codex-prompts.json")
|
|
}
|
|
|
|
// codexPrompts reads the instructions each model is sent with out of a
|
|
// /codex/models answer, or Codex CLI's models_cache.json, which is one.
|
|
func codexPrompts(b []byte) map[string]string {
|
|
var list struct {
|
|
Models []struct {
|
|
Slug string `json:"slug"`
|
|
Base string `json:"base_instructions"`
|
|
Messages struct {
|
|
Template string `json:"instructions_template"`
|
|
} `json:"model_messages"`
|
|
} `json:"models"`
|
|
}
|
|
if json.Unmarshal(b, &list) != nil {
|
|
return nil
|
|
}
|
|
out := map[string]string{}
|
|
for _, m := range list.Models {
|
|
if s := m.Messages.Template; s != "" {
|
|
out[m.Slug] = s
|
|
} else if m.Base != "" {
|
|
out[m.Slug] = m.Base
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// saveCodexPrompts keeps the instructions a /codex/models answer carries,
|
|
// adding to those of models it doesn't list (another account's).
|
|
func saveCodexPrompts(b []byte) {
|
|
fresh := codexPrompts(b)
|
|
if len(fresh) == 0 {
|
|
return
|
|
}
|
|
path := codexPromptsPath()
|
|
all := map[string]string{}
|
|
if old, err := os.ReadFile(path); err == nil {
|
|
json.Unmarshal(old, &all)
|
|
}
|
|
for k, v := range fresh {
|
|
all[k] = v
|
|
}
|
|
out, _ := json.Marshal(all)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return
|
|
}
|
|
if err := os.WriteFile(path, out, 0o644); err != nil {
|
|
return
|
|
}
|
|
// Two writes in one tick share an mtime, and promptFile.get would
|
|
// keep the first account's list. Drop it under the cache's lock.
|
|
savedPrompts.drop()
|
|
}
|
|
|
|
// promptFile is a file of instructions by model, read again when it changes.
|
|
type promptFile struct {
|
|
sync.Mutex
|
|
path string
|
|
mod time.Time
|
|
m map[string]string
|
|
}
|
|
|
|
// drop forgets the cached copy so the next get reads the file again even
|
|
// when its mtime has not moved.
|
|
func (f *promptFile) drop() {
|
|
f.Lock()
|
|
f.path, f.mod, f.m = "", time.Time{}, nil
|
|
f.Unlock()
|
|
}
|
|
|
|
func (f *promptFile) get(path, model string, parse func([]byte) map[string]string) (string, bool) {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
st, err := os.Stat(path)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
if path != f.path || !st.ModTime().Equal(f.mod) {
|
|
f.path, f.mod, f.m = path, st.ModTime(), nil
|
|
if b, err := os.ReadFile(path); err == nil {
|
|
f.m = parse(b)
|
|
}
|
|
}
|
|
s, ok := f.m[model]
|
|
return s, ok
|
|
}
|
|
|
|
var savedPrompts, codexCLIPrompts promptFile
|
|
|
|
// codexInstructions is what Codex CLI would send as a model's
|
|
// instructions: the account's model list's, Codex CLI's own copy of it
|
|
// when magpie hasn't asked yet, else Codex's generic prompt.
|
|
func codexInstructions(model string) string {
|
|
if s, ok := savedPrompts.get(codexPromptsPath(), model, func(b []byte) map[string]string {
|
|
var m map[string]string
|
|
json.Unmarshal(b, &m)
|
|
return m
|
|
}); ok {
|
|
return s
|
|
}
|
|
if s, ok := codexCLIPrompts.get(catalog.CodexModelsCache(), model, codexPrompts); ok {
|
|
return s
|
|
}
|
|
return codexcat.Prompt
|
|
}
|
|
|
|
// codexBody makes a Responses request the one Codex CLI would send: it
|
|
// streams only, keeps nothing (so its items carry no ids), carries Codex's
|
|
// instructions — the client's own riding first in the input — and rejects
|
|
// the sampling knobs and any service tier but Fast's.
|
|
func codexBody(body []byte) []byte {
|
|
dec := json.NewDecoder(bytes.NewReader(body))
|
|
dec.UseNumber()
|
|
var m map[string]any
|
|
if dec.Decode(&m) != nil || m == nil {
|
|
return body
|
|
}
|
|
for _, k := range []string{"max_output_tokens", "max_completion_tokens", "temperature", "top_p", "previous_response_id", "user", "safety_identifier"} {
|
|
delete(m, k)
|
|
}
|
|
// Codex's Fast mode sends "priority"; any other tier stays out
|
|
if m["service_tier"] != "priority" {
|
|
delete(m, "service_tier")
|
|
}
|
|
model, _ := m["model"].(string)
|
|
if s, ok := m["input"].(string); ok {
|
|
m["input"] = []any{map[string]any{"type": "message", "role": "user",
|
|
"content": []any{map[string]any{"type": "input_text", "text": s}}}}
|
|
}
|
|
input, _ := m["input"].([]any)
|
|
input = codexInput(input)
|
|
own, _ := m["instructions"].(string)
|
|
if k, _ := m["prompt_cache_key"].(string); k == "" {
|
|
if k = conversationKey(own, input); k != "" {
|
|
m["prompt_cache_key"] = k
|
|
}
|
|
}
|
|
|
|
// the client's instructions go first as a developer message, Codex's
|
|
// in their place — unless they are Codex's, from Codex itself
|
|
codex := codexInstructions(model)
|
|
if strings.TrimSpace(own) != "" {
|
|
if firstLine(own) == firstLine(codex) {
|
|
codex = own
|
|
} else {
|
|
input = append([]any{map[string]any{"type": "message", "role": "developer",
|
|
"content": []any{map[string]any{"type": "input_text", "text": own}}}}, input...)
|
|
}
|
|
}
|
|
if input == nil {
|
|
input = []any{}
|
|
}
|
|
m["input"] = input
|
|
if codex != "" {
|
|
m["instructions"] = codex
|
|
}
|
|
m["store"] = false
|
|
m["stream"] = true
|
|
m["tool_choice"] = "auto"
|
|
if _, ok := m["parallel_tool_calls"]; !ok {
|
|
m["parallel_tool_calls"] = true
|
|
}
|
|
if _, ok := m["text"]; !ok {
|
|
m["text"] = map[string]any{"verbosity": "medium"}
|
|
}
|
|
|
|
// reasoning, and its encrypted content to carry across turns, unless
|
|
// it is turned off; "ultra" is Codex CLI's name for sending "max"
|
|
reasoning, _ := m["reasoning"].(map[string]any)
|
|
effort, _ := reasoning["effort"].(string)
|
|
if effort == "ultra" {
|
|
reasoning["effort"] = "max"
|
|
}
|
|
if effort == "none" {
|
|
delete(m, "include")
|
|
} else {
|
|
if reasoning != nil {
|
|
if _, ok := reasoning["summary"]; !ok {
|
|
reasoning["summary"] = "auto"
|
|
}
|
|
}
|
|
include, _ := m["include"].([]any)
|
|
if !containsAny(include, "reasoning.encrypted_content") {
|
|
include = append(include, "reasoning.encrypted_content")
|
|
}
|
|
m["include"] = include
|
|
}
|
|
out, err := json.Marshal(m)
|
|
if err != nil {
|
|
return body
|
|
}
|
|
return out
|
|
}
|
|
|
|
// conversationKey names the conversation a request belongs to, for a client
|
|
// that doesn't (Codex sends its thread's id as prompt_cache_key): the
|
|
// backend keeps a conversation's cached prompt, and Codex CLI's session
|
|
// headers, by it. It is taken from how the conversation starts — the
|
|
// client's instructions and the input up to its first user message — which
|
|
// every later turn repeats, so each turn gets the same key.
|
|
func conversationKey(instructions string, input []any) string {
|
|
h := sha256.New()
|
|
h.Write([]byte(instructions))
|
|
user := false
|
|
for _, item := range input {
|
|
b, _ := json.Marshal(item)
|
|
h.Write(b)
|
|
if it, _ := item.(map[string]any); it["role"] == "user" {
|
|
user = true
|
|
break
|
|
}
|
|
}
|
|
if !user {
|
|
return ""
|
|
}
|
|
s := hex.EncodeToString(h.Sum(nil))
|
|
return s[:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20] + "-" + s[20:32]
|
|
}
|
|
|
|
// BoundCallID is a call's id as the ChatGPT backend takes one: as it is
|
|
// when it fits its 64 characters, else the sha256 of it (trimmed, as calls
|
|
// and their outputs are paired), 64 hex characters. Foreign providers can
|
|
// join two ids into one longer than that (#732, congee949: 86 and 87); the
|
|
// same id gives the same hash on every request, so a call and its output
|
|
// stay paired, and two ids that differ are never cut down alike.
|
|
func BoundCallID(id string) string {
|
|
if len(id) <= 64 {
|
|
return id
|
|
}
|
|
sum := sha256.Sum256([]byte(strings.TrimSpace(id)))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// codexInput makes input items fit a request that keeps nothing: no
|
|
// references to stored items, no ids, a tool's output without its call
|
|
// kept as a message saying what it was, and no system role, which the
|
|
// backend rejects (developer keeps what it means in its place).
|
|
func codexInput(input []any) []any {
|
|
calls := map[string]string{} // call id → the type of its output
|
|
for _, item := range input {
|
|
it, _ := item.(map[string]any)
|
|
id, _ := it["call_id"].(string)
|
|
switch it["type"] {
|
|
case "function_call", "function_call_output", "local_shell_call", "local_shell_call_output", "custom_tool_call", "custom_tool_call_output", "tool_search_call", "tool_search_output":
|
|
if b := BoundCallID(id); b != id {
|
|
id = b
|
|
it["call_id"] = id
|
|
}
|
|
}
|
|
switch it["type"] {
|
|
case "function_call":
|
|
calls[strings.TrimSpace(id)] = "function_call_output"
|
|
case "local_shell_call":
|
|
calls[strings.TrimSpace(id)] = "local_shell_call_output"
|
|
case "custom_tool_call":
|
|
calls[strings.TrimSpace(id)] = "custom_tool_call_output"
|
|
}
|
|
}
|
|
var out []any
|
|
for _, item := range input {
|
|
it, ok := item.(map[string]any)
|
|
if !ok {
|
|
out = append(out, item)
|
|
continue
|
|
}
|
|
if it["type"] == "item_reference" {
|
|
continue
|
|
}
|
|
// Codex delivers standalone notifications without a call ID. The
|
|
// native backend understands their name/namespace and item ID;
|
|
// they are not historical results whose calls have gone missing.
|
|
if (it["type"] == "function_call_output" || it["type"] == "custom_tool_call_output") && (it["call_id"] == nil || it["call_id"] == "") {
|
|
out = append(out, it)
|
|
continue
|
|
}
|
|
delete(it, "id")
|
|
switch t, _ := it["type"].(string); t {
|
|
case "function_call_output", "custom_tool_call_output", "local_shell_call_output":
|
|
id, _ := it["call_id"].(string)
|
|
id = strings.TrimSpace(id)
|
|
want := calls[id]
|
|
if want == t || t == "function_call_output" && want == "local_shell_call_output" {
|
|
break
|
|
}
|
|
it = orphanOutput(it, id)
|
|
}
|
|
if it["role"] == "system" && (it["type"] == nil || it["type"] == "message") {
|
|
it["role"] = "developer"
|
|
}
|
|
out = append(out, it)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// orphanOutput is a tool's output whose call isn't in the input, told as
|
|
// the assistant's note of it.
|
|
func orphanOutput(it map[string]any, id string) map[string]any {
|
|
name, _ := it["name"].(string)
|
|
if name == "" {
|
|
name = "tool"
|
|
}
|
|
if id == "" {
|
|
id = "unknown"
|
|
}
|
|
text, ok := it["output"].(string)
|
|
if !ok {
|
|
b, _ := json.Marshal(it["output"])
|
|
text = string(b)
|
|
}
|
|
if len(text) > 16000 {
|
|
text = strings.ToValidUTF8(text[:16000], "") + "\n...[truncated]"
|
|
}
|
|
return map[string]any{"type": "message", "role": "assistant",
|
|
"content": "[Previous " + name + " result; call_id=" + id + "]: " + text}
|
|
}
|
|
|
|
func firstLine(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
|
s = s[:i]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func containsAny(list []any, s string) bool {
|
|
for _, v := range list {
|
|
if v == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|