detached 9e3f5d2

Add Codex Luna and preview routing

commit
9e3f5d24390f4a882ffeafd2844627ecb3d41949
author
patriceckhart · · 2026-07-09 18:42 UTC
parent
c6b35a2
signature
unsigned
refs
tag: v0.2.70
4 files · +139 -16
M packages/provider/models.go +6 -12
@@ -316,18 +316,6 @@ var Catalog = []Model{
316316 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
317317 Speculative: true,
318318 },
319- {
320- Provider: "openai", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",
321- ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true,
322- PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
323- Speculative: true,
324- },
325- {
326- Provider: "openai", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra",
327- ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true,
328- PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25,
329- Speculative: true,
330- },
331319
332320 // ---- OpenAI Codex / ChatGPT subscription backend ----
333321 // Same model ids as the OpenAI family, but routed through the
@@ -356,6 +344,12 @@ var Catalog = []Model{
356344 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
357345 Speculative: true,
358346 },
347+ {
348+ Provider: "openai-codex", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna",
349+ ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true,
350+ PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1,
351+ Speculative: true,
352+ },
359353 {
360354 Provider: "openai-codex", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",
361355 ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true,
@@ -316,18 +316,6 @@ var Catalog = []Model{
316 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,316 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
317 Speculative: true,317 Speculative: true,
318 },318 },
319- {
320- Provider: "openai", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",
321- ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true,
322- PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
323- Speculative: true,
324- },
325- {
326- Provider: "openai", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra",
327- ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true,
328- PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25,
329- Speculative: true,
330- },
331 319
332 // ---- OpenAI Codex / ChatGPT subscription backend ----320 // ---- OpenAI Codex / ChatGPT subscription backend ----
333 // Same model ids as the OpenAI family, but routed through the321 // Same model ids as the OpenAI family, but routed through the
@@ -356,6 +344,12 @@ var Catalog = []Model{
356 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,344 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
357 Speculative: true,345 Speculative: true,
358 },346 },
347+ {
348+ Provider: "openai-codex", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna",
349+ ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true,
350+ PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1,
351+ Speculative: true,
352+ },
359 {353 {
360 Provider: "openai-codex", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",354 Provider: "openai-codex", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",
361 ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true,355 ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true,
M packages/provider/openai_codex.go +42 -2
@@ -3,7 +3,9 @@ package provider
33 import (
44 "bytes"
55 "context"
6+ "crypto/rand"
67 "encoding/base64"
8+ "encoding/hex"
79 "encoding/json"
810 "fmt"
911 "io"
@@ -146,6 +148,7 @@ type codexRequest struct {
146148 ParallelToolCalls bool `json:"parallel_tool_calls"`
147149 Include []string `json:"include,omitempty"`
148150 Reasoning *codexReasoningConfig `json:"reasoning,omitempty"`
151+ PromptCacheKey string `json:"prompt_cache_key,omitempty"`
149152 }
150153
151154 // ---- Request building ----
@@ -310,6 +313,29 @@ func splitCallID(id string) (string, string) {
310313 return id, ""
311314 }
312315
316+func usesCodexCLIRouting(model string) bool {
317+ switch model {
318+ case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro":
319+ return true
320+ default:
321+ return false
322+ }
323+}
324+
325+func newCodexSessionID() string {
326+ var b [16]byte
327+ if _, err := rand.Read(b[:]); err != nil {
328+ return fmt.Sprintf("zot-%d", time.Now().UnixNano())
329+ }
330+ return strings.Join([]string{
331+ hex.EncodeToString(b[0:4]),
332+ hex.EncodeToString(b[4:6]),
333+ hex.EncodeToString(b[6:8]),
334+ hex.EncodeToString(b[8:10]),
335+ hex.EncodeToString(b[10:16]),
336+ }, "-")
337+}
338+
313339 // ---- Streaming ----
314340
315341 func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
@@ -317,6 +343,11 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
317343 if err != nil {
318344 return nil, err
319345 }
346+ var codexCLISessionID string
347+ if usesCodexCLIRouting(wire.Model) {
348+ codexCLISessionID = newCodexSessionID()
349+ wire.PromptCacheKey = codexCLISessionID
350+ }
320351 body, err := json.Marshal(wire)
321352 if err != nil {
322353 return nil, err
@@ -332,8 +363,17 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
332363 httpReq.Header.Set("authorization", "Bearer "+c.token)
333364 httpReq.Header.Set("chatgpt-account-id", c.accountID)
334365 httpReq.Header.Set("openai-beta", "responses=experimental")
335- httpReq.Header.Set("originator", "zot")
336- httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
366+ if codexCLISessionID != "" {
367+ // Some preview models are only admitted or reliably served by the
368+ // ChatGPT Codex backend when the request follows Codex CLI routing
369+ // metadata. Keep this narrow so Sol retains zot's proven shape.
370+ httpReq.Header.Set("originator", "codex_cli_rs")
371+ httpReq.Header.Set("session-id", codexCLISessionID)
372+ httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")
373+ } else {
374+ httpReq.Header.Set("originator", "zot")
375+ httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
376+ }
337377 return httpReq, nil
338378 }
339379
@@ -3,7 +3,9 @@ package provider
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6+ "crypto/rand"
6 "encoding/base64"7 "encoding/base64"
8+ "encoding/hex"
7 "encoding/json"9 "encoding/json"
8 "fmt"10 "fmt"
9 "io"11 "io"
@@ -146,6 +148,7 @@ type codexRequest struct {
146 ParallelToolCalls bool `json:"parallel_tool_calls"`148 ParallelToolCalls bool `json:"parallel_tool_calls"`
147 Include []string `json:"include,omitempty"`149 Include []string `json:"include,omitempty"`
148 Reasoning *codexReasoningConfig `json:"reasoning,omitempty"`150 Reasoning *codexReasoningConfig `json:"reasoning,omitempty"`
151+ PromptCacheKey string `json:"prompt_cache_key,omitempty"`
149 }152 }
150 153
151 // ---- Request building ----154 // ---- Request building ----
@@ -310,6 +313,29 @@ func splitCallID(id string) (string, string) {
310 return id, ""313 return id, ""
311 }314 }
312 315
316+func usesCodexCLIRouting(model string) bool {
317+ switch model {
318+ case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro":
319+ return true
320+ default:
321+ return false
322+ }
323+}
324+
325+func newCodexSessionID() string {
326+ var b [16]byte
327+ if _, err := rand.Read(b[:]); err != nil {
328+ return fmt.Sprintf("zot-%d", time.Now().UnixNano())
329+ }
330+ return strings.Join([]string{
331+ hex.EncodeToString(b[0:4]),
332+ hex.EncodeToString(b[4:6]),
333+ hex.EncodeToString(b[6:8]),
334+ hex.EncodeToString(b[8:10]),
335+ hex.EncodeToString(b[10:16]),
336+ }, "-")
337+}
338+
313 // ---- Streaming ----339 // ---- Streaming ----
314 340
315 func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {341 func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
@@ -317,6 +343,11 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
317 if err != nil {343 if err != nil {
318 return nil, err344 return nil, err
319 }345 }
346+ var codexCLISessionID string
347+ if usesCodexCLIRouting(wire.Model) {
348+ codexCLISessionID = newCodexSessionID()
349+ wire.PromptCacheKey = codexCLISessionID
350+ }
320 body, err := json.Marshal(wire)351 body, err := json.Marshal(wire)
321 if err != nil {352 if err != nil {
322 return nil, err353 return nil, err
@@ -332,8 +363,17 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
332 httpReq.Header.Set("authorization", "Bearer "+c.token)363 httpReq.Header.Set("authorization", "Bearer "+c.token)
333 httpReq.Header.Set("chatgpt-account-id", c.accountID)364 httpReq.Header.Set("chatgpt-account-id", c.accountID)
334 httpReq.Header.Set("openai-beta", "responses=experimental")365 httpReq.Header.Set("openai-beta", "responses=experimental")
335- httpReq.Header.Set("originator", "zot")366+ if codexCLISessionID != "" {
336- httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))367+ // Some preview models are only admitted or reliably served by the
368+ // ChatGPT Codex backend when the request follows Codex CLI routing
369+ // metadata. Keep this narrow so Sol retains zot's proven shape.
370+ httpReq.Header.Set("originator", "codex_cli_rs")
371+ httpReq.Header.Set("session-id", codexCLISessionID)
372+ httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")
373+ } else {
374+ httpReq.Header.Set("originator", "zot")
375+ httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
376+ }
337 return httpReq, nil377 return httpReq, nil
338 }378 }
339 379
M packages/provider/openai_codex_test.go +90 -0
@@ -1,6 +1,11 @@
11 package provider
22
33 import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "io"
8+ "net/http"
49 "strings"
510 "testing"
611 )
@@ -61,3 +66,88 @@ func TestCodexImageToolResultMirror(t *testing.T) {
6166 t.Fatalf("mirrored user image was not serialized as input_image")
6267 }
6368 }
69+
70+type roundTripFunc func(*http.Request) (*http.Response, error)
71+
72+func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
73+
74+func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
75+ c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient)
76+ var gotReq *http.Request
77+ var gotBody codexRequest
78+ c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
79+ gotReq = r
80+ if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
81+ t.Fatal(err)
82+ }
83+ return &http.Response{
84+ StatusCode: http.StatusOK,
85+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
86+ Body: io.NopCloser(strings.NewReader("")),
87+ }, nil
88+ })
89+
90+ events, err := c.Stream(context.Background(), Request{
91+ Model: "gpt-5.6-terra",
92+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
93+ })
94+ if err != nil {
95+ t.Fatal(err)
96+ }
97+ for range events {
98+ }
99+
100+ if gotReq == nil {
101+ t.Fatal("request was not sent")
102+ }
103+ if gotReq.Header.Get("originator") != "codex_cli_rs" {
104+ t.Fatalf("originator = %q", gotReq.Header.Get("originator"))
105+ }
106+ if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {
107+ t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))
108+ }
109+ if gotBody.PromptCacheKey == "" {
110+ t.Fatal("prompt_cache_key was not set")
111+ }
112+ if gotReq.Header.Get("session-id") != gotBody.PromptCacheKey {
113+ t.Fatalf("session-id = %q, prompt_cache_key = %q", gotReq.Header.Get("session-id"), gotBody.PromptCacheKey)
114+ }
115+}
116+
117+func TestCodexSolKeepsZotShape(t *testing.T) {
118+ c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient)
119+ var gotReq *http.Request
120+ var body bytes.Buffer
121+ c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
122+ gotReq = r
123+ _, _ = body.ReadFrom(r.Body)
124+ return &http.Response{
125+ StatusCode: http.StatusOK,
126+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
127+ Body: io.NopCloser(strings.NewReader("")),
128+ }, nil
129+ })
130+
131+ events, err := c.Stream(context.Background(), Request{
132+ Model: "gpt-5.6-sol",
133+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
134+ })
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ for range events {
139+ }
140+
141+ if gotReq == nil {
142+ t.Fatal("request was not sent")
143+ }
144+ if gotReq.Header.Get("originator") != "zot" {
145+ t.Fatalf("originator = %q", gotReq.Header.Get("originator"))
146+ }
147+ if gotReq.Header.Get("session-id") != "" {
148+ t.Fatalf("session-id = %q", gotReq.Header.Get("session-id"))
149+ }
150+ if strings.Contains(body.String(), "prompt_cache_key") {
151+ t.Fatalf("Sol request unexpectedly included prompt_cache_key: %s", body.String())
152+ }
153+}
@@ -1,6 +1,11 @@
1 package provider1 package provider
2 2
3 import (3 import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "io"
8+ "net/http"
4 "strings"9 "strings"
5 "testing"10 "testing"
6 )11 )
@@ -61,3 +66,88 @@ func TestCodexImageToolResultMirror(t *testing.T) {
61 t.Fatalf("mirrored user image was not serialized as input_image")66 t.Fatalf("mirrored user image was not serialized as input_image")
62 }67 }
63 }68 }
69+
70+type roundTripFunc func(*http.Request) (*http.Response, error)
71+
72+func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
73+
74+func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
75+ c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient)
76+ var gotReq *http.Request
77+ var gotBody codexRequest
78+ c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
79+ gotReq = r
80+ if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
81+ t.Fatal(err)
82+ }
83+ return &http.Response{
84+ StatusCode: http.StatusOK,
85+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
86+ Body: io.NopCloser(strings.NewReader("")),
87+ }, nil
88+ })
89+
90+ events, err := c.Stream(context.Background(), Request{
91+ Model: "gpt-5.6-terra",
92+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
93+ })
94+ if err != nil {
95+ t.Fatal(err)
96+ }
97+ for range events {
98+ }
99+
100+ if gotReq == nil {
101+ t.Fatal("request was not sent")
102+ }
103+ if gotReq.Header.Get("originator") != "codex_cli_rs" {
104+ t.Fatalf("originator = %q", gotReq.Header.Get("originator"))
105+ }
106+ if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {
107+ t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))
108+ }
109+ if gotBody.PromptCacheKey == "" {
110+ t.Fatal("prompt_cache_key was not set")
111+ }
112+ if gotReq.Header.Get("session-id") != gotBody.PromptCacheKey {
113+ t.Fatalf("session-id = %q, prompt_cache_key = %q", gotReq.Header.Get("session-id"), gotBody.PromptCacheKey)
114+ }
115+}
116+
117+func TestCodexSolKeepsZotShape(t *testing.T) {
118+ c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient)
119+ var gotReq *http.Request
120+ var body bytes.Buffer
121+ c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
122+ gotReq = r
123+ _, _ = body.ReadFrom(r.Body)
124+ return &http.Response{
125+ StatusCode: http.StatusOK,
126+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
127+ Body: io.NopCloser(strings.NewReader("")),
128+ }, nil
129+ })
130+
131+ events, err := c.Stream(context.Background(), Request{
132+ Model: "gpt-5.6-sol",
133+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
134+ })
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ for range events {
139+ }
140+
141+ if gotReq == nil {
142+ t.Fatal("request was not sent")
143+ }
144+ if gotReq.Header.Get("originator") != "zot" {
145+ t.Fatalf("originator = %q", gotReq.Header.Get("originator"))
146+ }
147+ if gotReq.Header.Get("session-id") != "" {
148+ t.Fatalf("session-id = %q", gotReq.Header.Get("session-id"))
149+ }
150+ if strings.Contains(body.String(), "prompt_cache_key") {
151+ t.Fatalf("Sol request unexpectedly included prompt_cache_key: %s", body.String())
152+ }
153+}
M packages/provider/provider_test.go +1 -2
@@ -393,8 +393,7 @@ func TestGPT56CatalogEntries(t *testing.T) {
393393 priceOut float64
394394 cacheRead float64
395395 }{
396- {"openai", "gpt-5.6-sol", 5, 30, 0.5},
397- {"openai", "gpt-5.6-terra", 2.5, 15, 0.25},
396+ {"openai-codex", "gpt-5.6-luna", 1, 6, 0.1},
398397 {"openai-codex", "gpt-5.6-sol", 5, 30, 0.5},
399398 {"openai-codex", "gpt-5.6-terra", 2.5, 15, 0.25},
400399 }
@@ -393,8 +393,7 @@ func TestGPT56CatalogEntries(t *testing.T) {
393 priceOut float64393 priceOut float64
394 cacheRead float64394 cacheRead float64
395 }{395 }{
396- {"openai", "gpt-5.6-sol", 5, 30, 0.5},396+ {"openai-codex", "gpt-5.6-luna", 1, 6, 0.1},
397- {"openai", "gpt-5.6-terra", 2.5, 15, 0.25},
398 {"openai-codex", "gpt-5.6-sol", 5, 30, 0.5},397 {"openai-codex", "gpt-5.6-sol", 5, 30, 0.5},
399 {"openai-codex", "gpt-5.6-terra", 2.5, 15, 0.25},398 {"openai-codex", "gpt-5.6-terra", 2.5, 15, 0.25},
400 }399 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close