ce7cb702218693d08ea6d83464d2457c9250027c
This commit is contained in:
commit
34c5f8963c
5 changed files with 145336 additions and 0 deletions
261
services/codex-bridge/chat.go
Normal file
261
services/codex-bridge/chat.go
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type message struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
|
||||
type chatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []message `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
Tools []json.RawMessage `json:"tools"`
|
||||
Reasoning string `json:"reasoning_effort"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func apiError(w http.ResponseWriter, status int, err error) {
|
||||
writeJSON(w, status, object{"error": object{"message": err.Error(), "type": "bridge_error"}})
|
||||
}
|
||||
|
||||
func (b *bridge) models(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := b.start(r.Context())
|
||||
if err != nil {
|
||||
apiError(w, http.StatusBadGateway, err)
|
||||
return
|
||||
}
|
||||
defer c.close()
|
||||
models := []object{}
|
||||
var cursor *string
|
||||
for {
|
||||
raw, err := c.call("model/list", object{"cursor": cursor})
|
||||
if err != nil {
|
||||
apiError(w, http.StatusBadGateway, err)
|
||||
return
|
||||
}
|
||||
var page struct {
|
||||
Data []struct {
|
||||
Model string `json:"model"`
|
||||
Name string `json:"displayName"`
|
||||
} `json:"data"`
|
||||
Next *string `json:"nextCursor"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &page); err != nil {
|
||||
apiError(w, http.StatusBadGateway, err)
|
||||
return
|
||||
}
|
||||
for _, model := range page.Data {
|
||||
models = append(models, object{"id": model.Model, "name": model.Name, "object": "model", "created": 0, "owned_by": "openai"})
|
||||
}
|
||||
if page.Next == nil {
|
||||
break
|
||||
}
|
||||
cursor = page.Next
|
||||
}
|
||||
writeJSON(w, http.StatusOK, object{"object": "list", "data": models})
|
||||
}
|
||||
|
||||
func contentText(raw json.RawMessage) (string, error) {
|
||||
var text string
|
||||
if json.Unmarshal(raw, &text) == nil && string(raw) != "null" {
|
||||
return text, nil
|
||||
}
|
||||
var parts []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parts); err != nil || string(raw) == "null" {
|
||||
return "", errors.New("message content must be text")
|
||||
}
|
||||
var result strings.Builder
|
||||
for _, part := range parts {
|
||||
if part.Type != "text" {
|
||||
return "", errors.New("only text content is supported")
|
||||
}
|
||||
result.WriteString(part.Text)
|
||||
}
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
func inputs(req chatRequest) ([]object, string, error) {
|
||||
if len(req.Tools) != 0 {
|
||||
return nil, "", errors.New("tools are managed by Codex through MCP; omit client tools")
|
||||
}
|
||||
if req.Model == "" || len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Role != "user" {
|
||||
return nil, "", errors.New("model and messages ending in a user message are required")
|
||||
}
|
||||
items := []object{}
|
||||
for i, msg := range req.Messages {
|
||||
role := msg.Role
|
||||
if role == "system" {
|
||||
role = "developer"
|
||||
}
|
||||
if role != "user" && role != "assistant" && role != "developer" {
|
||||
return nil, "", fmt.Errorf("unsupported message role: %s", msg.Role)
|
||||
}
|
||||
text, err := contentText(msg.Content)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if i == len(req.Messages)-1 {
|
||||
return items, text, nil
|
||||
}
|
||||
kind := "input_text"
|
||||
if role == "assistant" {
|
||||
kind = "output_text"
|
||||
}
|
||||
items = append(items, object{"type": "message", "role": role, "content": []object{{"type": kind, "text": text}}})
|
||||
}
|
||||
return nil, "", errors.New("missing user message")
|
||||
}
|
||||
|
||||
func (b *bridge) chat(w http.ResponseWriter, r *http.Request) {
|
||||
var req chatRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
apiError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
items, prompt, err := inputs(req)
|
||||
if err != nil {
|
||||
apiError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
c, err := b.start(r.Context())
|
||||
if err != nil {
|
||||
apiError(w, http.StatusBadGateway, err)
|
||||
return
|
||||
}
|
||||
defer c.close()
|
||||
raw, err := c.call("thread/start", object{"model": req.Model, "ephemeral": true, "approvalPolicy": "never", "sandbox": "read-only"})
|
||||
var thread struct {
|
||||
Thread struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"thread"`
|
||||
}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(raw, &thread)
|
||||
}
|
||||
if err == nil && len(items) > 0 {
|
||||
_, err = c.call("thread/inject_items", object{"threadId": thread.Thread.ID, "items": items})
|
||||
}
|
||||
if err != nil {
|
||||
apiError(w, http.StatusBadGateway, err)
|
||||
return
|
||||
}
|
||||
params := object{"threadId": thread.Thread.ID, "input": []object{{"type": "text", "text": prompt}}}
|
||||
if req.Reasoning != "" {
|
||||
params["effort"] = req.Reasoning
|
||||
}
|
||||
if _, err = c.call("turn/start", params); err != nil {
|
||||
apiError(w, http.StatusBadGateway, err)
|
||||
return
|
||||
}
|
||||
created := time.Now().Unix()
|
||||
id := "chatcmpl-" + thread.Thread.ID
|
||||
var answer strings.Builder
|
||||
usage := object{}
|
||||
lastItem := ""
|
||||
if req.Stream {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
emit := func(delta object, finish any, counts object) error {
|
||||
value := object{"id": id, "object": "chat.completion.chunk", "created": created, "model": req.Model, "choices": []object{{"index": 0, "delta": delta, "finish_reason": finish}}}
|
||||
if counts != nil {
|
||||
value["usage"] = counts
|
||||
}
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
|
||||
return err
|
||||
}
|
||||
return http.NewResponseController(w).Flush()
|
||||
}
|
||||
for {
|
||||
e, readErr := c.next()
|
||||
if readErr != nil {
|
||||
err = readErr
|
||||
break
|
||||
}
|
||||
var p struct {
|
||||
ThreadID string `json:"threadId"`
|
||||
ItemID string `json:"itemId"`
|
||||
Delta string `json:"delta"`
|
||||
Usage struct {
|
||||
Total struct {
|
||||
Input int `json:"inputTokens"`
|
||||
Output int `json:"outputTokens"`
|
||||
Total int `json:"totalTokens"`
|
||||
} `json:"total"`
|
||||
} `json:"tokenUsage"`
|
||||
Turn struct {
|
||||
Status string `json:"status"`
|
||||
Error *rpcError `json:"error"`
|
||||
} `json:"turn"`
|
||||
}
|
||||
if err = json.Unmarshal(e.Params, &p); err != nil {
|
||||
break
|
||||
}
|
||||
if p.ThreadID != thread.Thread.ID {
|
||||
continue
|
||||
}
|
||||
switch e.Method {
|
||||
case "item/agentMessage/delta":
|
||||
text := p.Delta
|
||||
if lastItem != "" && lastItem != p.ItemID {
|
||||
text = "\n" + text
|
||||
}
|
||||
lastItem = p.ItemID
|
||||
answer.WriteString(text)
|
||||
if req.Stream {
|
||||
err = emit(object{"role": "assistant", "content": text}, nil, nil)
|
||||
}
|
||||
case "thread/tokenUsage/updated":
|
||||
usage = object{"prompt_tokens": p.Usage.Total.Input, "completion_tokens": p.Usage.Total.Output, "total_tokens": p.Usage.Total.Total}
|
||||
case "turn/completed":
|
||||
if p.Turn.Status != "completed" {
|
||||
err = fmt.Errorf("codex turn %s", p.Turn.Status)
|
||||
if p.Turn.Error != nil {
|
||||
err = errors.New(p.Turn.Error.Message)
|
||||
}
|
||||
break
|
||||
}
|
||||
if req.Stream {
|
||||
if emit(object{}, "stop", usage) == nil {
|
||||
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
}
|
||||
} else {
|
||||
writeJSON(w, http.StatusOK, object{"id": id, "object": "chat.completion", "created": created, "model": req.Model, "choices": []object{{"index": 0, "message": object{"role": "assistant", "content": answer.String()}, "finish_reason": "stop"}}, "usage": usage})
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if r.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
if req.Stream {
|
||||
data, _ := json.Marshal(object{"error": object{"message": err.Error(), "type": "bridge_error"}})
|
||||
_, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", data)
|
||||
} else {
|
||||
apiError(w, http.StatusBadGateway, err)
|
||||
}
|
||||
}
|
||||
65
services/codex-bridge/chat_test.go
Normal file
65
services/codex-bridge/chat_test.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInvalidChat(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{`,
|
||||
`{"model":"m","messages":[]}`,
|
||||
`{"model":"m","messages":[{"role":"assistant","content":"text"}]}`,
|
||||
`{"model":"m","messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://example.com/image"}}]}]}`,
|
||||
`{"model":"m","messages":[{"role":"user","content":null}]}`,
|
||||
`{"model":"m","messages":[{"role":"user","content":"text"}],"tools":[{"type":"function"}]}`,
|
||||
} {
|
||||
t.Run(body, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
(&bridge{binary: "/nonexistent"}).handler().ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body)))
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryRoles(t *testing.T) {
|
||||
var req chatRequest
|
||||
err := json.Unmarshal([]byte(`{"model":"m","messages":[{"role":"system","content":"Instruction"},{"role":"user","content":"Question"},{"role":"assistant","content":"Answer"},{"role":"user","content":[{"type":"text","text":"Follow"},{"type":"text","text":"up"}]}]}`), &req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, prompt, err := inputs(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prompt != "Followup" || len(items) != 3 {
|
||||
t.Fatalf("%v %q", items, prompt)
|
||||
}
|
||||
for i, role := range []string{"developer", "user", "assistant"} {
|
||||
if items[i]["role"] != role {
|
||||
t.Fatalf("history %v", items)
|
||||
}
|
||||
}
|
||||
if got := items[2]["content"].([]object)[0]; got["type"] != "output_text" || got["text"] != "Answer" {
|
||||
t.Fatalf("assistant content %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnavailableCodex(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
(&bridge{binary: "/nonexistent"}).handler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/models", nil))
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status %d", w.Code)
|
||||
}
|
||||
var result struct {
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil || result.Error == nil {
|
||||
t.Fatalf("invalid error: %s", w.Body)
|
||||
}
|
||||
}
|
||||
3
services/codex-bridge/go.mod
Normal file
3
services/codex-bridge/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module github.com/quorate/ms2k-internal/services/codex-bridge
|
||||
|
||||
go 1.26.4
|
||||
61
services/codex-bridge/main.go
Normal file
61
services/codex-bridge/main.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type bridge struct {
|
||||
binary string
|
||||
dir string
|
||||
mcpURL string
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Print(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
b := &bridge{}
|
||||
addr := flag.String("listen", "127.0.0.1:8080", "HTTP listen address")
|
||||
flag.StringVar(&b.binary, "codex", "codex", "Codex executable")
|
||||
flag.StringVar(&b.dir, "workdir", ".", "Codex working directory")
|
||||
flag.StringVar(&b.mcpURL, "mcp-url", "", "AgentGateway MCP endpoint; otherwise use Codex configuration")
|
||||
flag.Parse()
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
s := &http.Server{Addr: *addr, Handler: b.handler(), ReadHeaderTimeout: 10 * time.Second, BaseContext: func(net.Listener) context.Context { return ctx }}
|
||||
stopped := make(chan error, 1)
|
||||
go func() { stopped <- s.ListenAndServe() }()
|
||||
var serveErr error
|
||||
select {
|
||||
case err := <-stopped:
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
serveErr = err
|
||||
}
|
||||
case <-ctx.Done():
|
||||
}
|
||||
stop()
|
||||
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return errors.Join(serveErr, s.Shutdown(shutdown))
|
||||
}
|
||||
|
||||
func (b *bridge) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
mux.HandleFunc("GET /v1/models", b.models)
|
||||
mux.HandleFunc("POST /v1/chat/completions", b.chat)
|
||||
return mux
|
||||
}
|
||||
123
services/codex-bridge/rpc.go
Normal file
123
services/codex-bridge/rpc.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type object = map[string]any
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type event struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
|
||||
type client struct {
|
||||
cmd *exec.Cmd
|
||||
input *json.Encoder
|
||||
output *json.Decoder
|
||||
id int
|
||||
queued []event
|
||||
}
|
||||
|
||||
func (b *bridge) start(ctx context.Context) (*client, error) {
|
||||
args := []string{"app-server"}
|
||||
if b.mcpURL != "" {
|
||||
url, err := json.Marshal(b.mcpURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, "-c", "mcp_servers.gateway.url="+string(url), "-c", "mcp_servers.gateway.required=true")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, b.binary, args...)
|
||||
cmd.Dir = b.dir
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }
|
||||
in, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
_ = in.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = in.Close()
|
||||
_ = out.Close()
|
||||
return nil, err
|
||||
}
|
||||
c := &client{cmd: cmd, input: json.NewEncoder(in), output: json.NewDecoder(out)}
|
||||
if _, err := c.call("initialize", object{"clientInfo": object{"name": "codex_bridge", "version": "0.1.0"}, "capabilities": object{"experimentalApi": true}}); err != nil {
|
||||
c.close()
|
||||
return nil, err
|
||||
}
|
||||
if err := c.input.Encode(object{"method": "initialized", "params": object{}}); err != nil {
|
||||
c.close()
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *client) close() {
|
||||
_ = syscall.Kill(-c.cmd.Process.Pid, syscall.SIGKILL)
|
||||
_ = c.cmd.Wait()
|
||||
}
|
||||
|
||||
func (c *client) read() (event, error) {
|
||||
var e event
|
||||
if err := c.output.Decode(&e); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return e, errors.New("codex app-server closed its output")
|
||||
}
|
||||
return e, err
|
||||
}
|
||||
if len(e.ID) > 0 && e.Method != "" {
|
||||
return e, fmt.Errorf("unsupported Codex client request: %s", e.Method)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (c *client) call(method string, params object) (json.RawMessage, error) {
|
||||
c.id++
|
||||
id := fmt.Sprint(c.id)
|
||||
if err := c.input.Encode(object{"id": c.id, "method": method, "params": params}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
e, err := c.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if string(e.ID) != id {
|
||||
c.queued = append(c.queued, e)
|
||||
continue
|
||||
}
|
||||
if e.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", method, e.Error.Message)
|
||||
}
|
||||
return e.Result, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) next() (event, error) {
|
||||
if len(c.queued) == 0 {
|
||||
return c.read()
|
||||
}
|
||||
e := c.queued[0]
|
||||
c.queued = c.queued[1:]
|
||||
return e, nil
|
||||
}
|
||||
Loading…
Reference in a new issue