ce7cb702218693d08ea6d83464d2457c9250027c

This commit is contained in:
ms2k 2026-11-12 20:46:28 +09:00
commit 34c5f8963c
5 changed files with 145336 additions and 0 deletions

View 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)
}
}

View 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)
}
}

View file

@ -0,0 +1,3 @@
module github.com/quorate/ms2k-internal/services/codex-bridge
go 1.26.4

View 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
}

View 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
}