Compare commits

..
4 Commits
Author SHA1 Message Date
Ilker ManapandClaude Opus 5.5 3558dba485 v0.11.4
Release / build (push) Successful in 4m19s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m36s
The OpenAPI document carries serve's tools field.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 12:29:05 +02:00
Ilker ManapandClaude Opus 5.5 481523ca46 serve: tool calls declared, slots that hold the context, longer answers
CI / test (push) Failing after 2m41s
A --context-length 32768 --ctx-pool 32768 model got llama.cpp's default four
slots of 8192, and refused what the gateway admitted; --parallel is now
what the pool holds of the context. max_output was 1024 for every model,
which cut an editor's agent off mid-file; it is now a quarter of the
context, from 1024 to 8192. --tools writes capabilities.tools, which the
Continue configuration now reads, as it reads max_output.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 09:30:40 +02:00
Ilker ManapandClaude Opus 5.5 df6d243ee7 The Continue configuration says what the catalog says of the model
CI / test (push) Successful in 2m52s
It told Continue every model uses tools and nothing of its context: a model
whose calls come back as text stalled Continue's agent, and Continue sent
prompts far over a CPU replica's 4096 tokens, each refused. tool_use is now
written for a model whose catalog entry says tools, and the context length
with a fitting maxTokens whenever the catalog knows it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 09:16:34 +02:00
Ilker ManapandClaude Opus 5.5 ef33e52d3d ctl upgrade upgrades the leader last, wherever it is run
CI / test (push) Successful in 2m39s
Run on the standby, it upgraded the leader first, then itself, and the
leadership moved twice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 06:45:56 +02:00
14 changed files with 256 additions and 37 deletions

No files matched your search

+17 -1
View File
@@ -21,7 +21,7 @@ is only released once its **exit criterion** is met, not when its code lands.
Until v1.0.0 the minor version is the breaking one: configuration formats and Go
APIs change between minors without a deprecation period.
## Unreleased
## v0.11.4 — 2026-10-05
Two control planes, run in anger on the reference cluster.
@@ -53,8 +53,24 @@ Two control planes, run in anger on the reference cluster.
dashboard as well as the API; a proxy left pointing at a standby showed an
empty cluster. The leader trusts the control planes' `X-Forwarded-For`.
- `ctl model serve --tools` says in the catalog that a model makes tool
calls, for agents and editors.
### Fixed
- **Continue's agent did nothing.** The configuration the portal writes told
Continue every model uses tools, and nothing of its context: a model whose
calls came back as text stalled the agent, and prompts over a CPU
replica's 4096 tokens were refused. It now says `tool_use` only for a
model whose catalog says tools, and the context and longest answer the
gateway allows.
- `ctl model serve` gave llama.cpp four slots whatever was asked: a
`--context-length 32768 --ctx-pool 32768` model had slots of 8192 and
refused what the gateway admitted. Slots are now what the pool holds of
the context. The longest answer grows with the context (a quarter, from
1024 to 8192) rather than being 1024 for every model.
- `ctl upgrade` upgrades the leader last wherever it is run; run on the
standby, it upgraded the leader first and the leadership moved twice.
- **Failover took 40 seconds; it takes a few.** A stopping control plane held
every agent's stream for the thirty seconds of a graceful shutdown; it now
gives up its lease first and lets the agents go at once.
+5 -1
View File
@@ -7001,11 +7001,15 @@
},
"ctx_pool": {
"type": "integer",
"description": "CtxPool is llama.cpp's --ctx-size; ContextLength what one request may use. They default to 16384 and 4096, what four parallel slots divide."
"description": "CtxPool is llama.cpp's --ctx-size; ContextLength what one request may use. They default to 16384 and 4096, what four parallel slots divide; llama.cpp is given as many slots as the pool holds requests."
},
"context_length": {
"type": "integer"
},
"tools": {
"type": "boolean",
"description": "Tools says the model makes tool calls the engine returns as calls: it is written into the catalog, where clients and editors read it. Check a running replica with llmgw ctl tool-check before saying so."
},
"cpu_cores": {
"type": "integer",
"description": "CPUCores is what a replica computes on; zero takes half the smallest node it fits on."
+9
View File
@@ -184,6 +184,15 @@ asks for, send the results back. Whether a model calls tools well is the
model's business, not the gateway's; ask your operator which of the served
models are set up for it.
**Editors' agents need such a model.** Continue's Agent mode, Cline and
Codex act only through tool calls: with a model whose calls come back as
text — `<tool_call>{...}` or a JSON block in the answer, `tool_calls` empty —
they chat but never read a file or run a command. Qwen2.5-Coder is one;
Qwen3-Coder is not. The configuration the portal writes for Continue says
`tool_use` only for a model whose catalog entry says tools, and gives
Continue the model's context and longest answer, so it never sends a request
the gateway refuses.
```python
import json
+5 -4
View File
@@ -2259,12 +2259,13 @@ On a control plane:
sudo --preserve-env=SSH_AUTH_SOCK llmgw ctl upgrade v0.10.4
```
With two control planes it upgrades the other one first, over ssh as the user
who ran sudo (`--ssh-user`), with `llmgw upgrade` there; then this machine —
With two control planes it upgrades the standby first and the leader last,
whichever this machine is — the other over ssh as the user who ran sudo
(`--ssh-user`), with `llmgw upgrade` there, and this machine —
fetching the release for its platform, checking it against the release's
checksums, keeping the old binary as `/usr/local/bin/llmgw.bak-pre-<version>`,
restarting `llmgw-server` and waiting for every node to reconnect. A leader
restarted last hands over to a standby already on the release.
restarting `llmgw-server` and waiting for every node to reconnect. The
leader's restart hands over once, to a standby already on the release.
Then the nodes, in waves: one first, then two, four, and so on up to
`--parallel` (20) at once. A node's agent is told the version over the stream
+33 -8
View File
@@ -68,14 +68,26 @@ func newUpgradeCmd(client func() *ctlClient) *cobra.Command {
}
}
if !skipCP {
if othersCP {
if err := upgradeOtherControlPlanes(ctx, out, client, v, releases, sshUser); err != nil {
// The leader last, wherever this runs: every other control
// plane is a standby, upgraded with nothing moving, and the
// leader's restart hands over once, to a machine already on
// the release.
others := func() error {
if !othersCP {
return nil
}
return upgradeOtherControlPlanes(ctx, out, client, v, releases, sshUser)
}
self := func() error { return upgradeControlPlane(ctx, out, client, v, releases, serverSvc) }
order := []func() error{others, self}
if !thisLeads(ctx, client) {
order = []func() error{self, others}
}
for _, step := range order {
if err := step(); err != nil {
return err
}
}
if err := upgradeControlPlane(ctx, out, client, v, releases, serverSvc); err != nil {
return err
}
}
return upgradeNodes(ctx, out, client, v, nodes, perNode, parallel)
},
@@ -92,10 +104,23 @@ func newUpgradeCmd(client func() *ctlClient) *cobra.Command {
return c
}
// thisLeads says whether this machine's control plane leads; one that does
// not say, alone or older, is taken to.
func thisLeads(ctx context.Context, client func() *ctlClient) bool {
var sum adminapi.Summary
if err := client().get(ctx, "/api/v1/summary", &sum); err != nil {
return true
}
for _, cp := range sum.ControlPlanes.Members {
if cp.This {
return cp.State == "leading"
}
}
return true
}
// upgradeOtherControlPlanes moves every other control plane of the cluster to
// v, over ssh with `llmgw upgrade`, before this machine: each is a standby, or
// becomes one when this one is restarted, and the leadership should move to
// a machine already on the release. Nothing is changed here if one cannot be
// v, over ssh with `llmgw upgrade`. Nothing is changed here if one cannot be
// reached.
func upgradeOtherControlPlanes(ctx context.Context, out io.Writer, client func() *ctlClient, v, releases, user string) error {
var sum adminapi.Summary
+20
View File
@@ -111,3 +111,23 @@ func TestAnUpgradeGoesInWavesThatGrowUpToTheLimit(t *testing.T) {
t.Errorf("waves %s:\n%s", got, out.String())
}
}
func TestAnUpgradeKnowsWhetherItRunsOnTheLeader(t *testing.T) {
sum := adminapi.Summary{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(sum)
}))
defer srv.Close()
client := func() *ctlClient { return newCtlClient(srv.URL) }
if !thisLeads(t.Context(), client) {
t.Error("a control plane with no rival does not lead")
}
sum.ControlPlanes.Members = []adminapi.ControlPlane{{Name: "a", State: "leading"}, {Name: "b", State: "standing by", This: true}}
if thisLeads(t.Context(), client) {
t.Error("a standby took itself for the leader: it would upgrade the leader before itself")
}
sum.ControlPlanes.Members[0].This, sum.ControlPlanes.Members[1].This = true, false
if !thisLeads(t.Context(), client) {
t.Error("the leader took itself for a standby")
}
}
+2
View File
@@ -73,6 +73,8 @@ func newModelServeCmd(client func() *ctlClient) *cobra.Command {
f.StringSliceVar(&req.Aliases, "alias", nil, "other names clients may use; repeatable")
f.StringVar(&req.Description, "description", "", "what it is for, shown to clients in /v1/models")
f.IntVar(&req.Replicas, "replicas", 1, "how many replicas to run")
f.BoolVar(&req.Tools, "tools", false,
"say it makes tool calls (for agents and editors); check a running one with llmgw ctl tool-check")
f.StringVar(&req.GuardFormat, "guard-format", "",
"serve it as the guardrails' judge (role: guard), read as llamaguard3, shieldgemma or json; hidden from clients")
f.BoolVar(&onDemand, "on-demand", false, "run none until first asked for")
+31 -5
View File
@@ -140,9 +140,14 @@ type Request struct {
// llamaguard3, shieldgemma or json. Empty serves clients.
GuardFormat string `json:"guard_format,omitempty"`
// CtxPool is llama.cpp's --ctx-size; ContextLength what one request may
// use. They default to 16384 and 4096, what four parallel slots divide.
// use. They default to 16384 and 4096, what four parallel slots divide;
// llama.cpp is given as many slots as the pool holds requests.
CtxPool int `json:"ctx_pool,omitempty"`
ContextLength int `json:"context_length,omitempty"`
// Tools says the model makes tool calls the engine returns as calls: it
// is written into the catalog, where clients and editors read it. Check
// a running replica with llmgw ctl tool-check before saying so.
Tools bool `json:"tools,omitempty"`
// CPUCores is what a replica computes on; zero takes half the smallest
// node it fits on.
CPUCores int `json:"cpu_cores,omitempty"`
@@ -261,6 +266,10 @@ func (s *Server) Start(req Request) (Job, error) {
if req.ContextLength <= 0 {
req.ContextLength = 4096
}
if req.ContextLength > req.CtxPool {
return Job{}, fmt.Errorf("a request of %d tokens cannot fit a pool of %d: give --ctx-pool at least --context-length",
req.ContextLength, req.CtxPool)
}
s.mu.Lock()
s.seq++
j := &Job{ID: fmt.Sprintf("serve-%d-%d", time.Now().Unix(), s.seq), Request: req,
@@ -545,12 +554,12 @@ func profilesFor(layout []modelplan.Option, req Request, memGB, cores int) []cat
switch {
case o.CPU:
p.Name = "cpu"
p.Args = []string{"--ctx-size", strconv.Itoa(req.CtxPool)}
p.Args = llamaArgs(req)
p.MinMemoryGB, p.CPUCores = memGB, cores
case o.Engine == "llamacpp":
p.Name = fmt.Sprintf("gpu-%dx%s", o.GPUsPerNode, o.Class)
p.GPUClass, p.GPUsPerNode = o.Class, o.GPUsPerNode
p.Args = []string{"--ctx-size", strconv.Itoa(req.CtxPool)}
p.Args = llamaArgs(req)
default:
p.Name = fmt.Sprintf("gpu-%dx%s", o.GPUs(), o.Class)
p.GPUClass, p.TP = o.Class, o.GPUsPerNode
@@ -564,6 +573,23 @@ func profilesFor(layout []modelplan.Option, req Request, memGB, cores int) []cat
return out
}
// llamaArgs size llama.cpp's pool and its slots together. The pool is shared
// by the slots, so slots of the context the catalog promises are as many as
// the pool holds: the engine's default of four turned a 32768 pool into
// slots of 8192, and the gateway, told 32768, sent requests the engine then
// refused.
func llamaArgs(req Request) []string {
return []string{"--ctx-size", strconv.Itoa(req.CtxPool),
"--parallel", strconv.Itoa(max(1, req.CtxPool/req.ContextLength))}
}
// maxOutput is the longest answer the catalog allows: a quarter of the
// context, from 1024 to 8192. 1024 everywhere cut an editor's agent off in
// the middle of the file it was writing.
func maxOutput(contextLength int) int {
return min(8192, max(1024, contextLength/4))
}
// addAndStart puts the model in the catalog with its profile, waits for a
// replica and asks it to answer.
// hardwareExists is whether the cluster has what the ways chosen need, busy
@@ -583,10 +609,10 @@ func (s *Server) addAndStart(ctx context.Context, j *Job, repo hf.Repo, set hf.F
// 4. The catalog.
m := catalog.Model{
Name: name, Aliases: req.Aliases, Description: req.Description, Source: source,
RoutingProfile: "chat", ContextLength: req.ContextLength, MaxOutput: 1024,
RoutingProfile: "chat", ContextLength: req.ContextLength, MaxOutput: maxOutput(req.ContextLength),
Profiles: profs,
Scaling: catalog.Scaling{Min: req.Replicas, Max: max(req.Replicas, 1)},
Capabilities: catalog.Capabilities{Vision: req.Projector != ""},
Capabilities: catalog.Capabilities{Vision: req.Projector != "", Tools: req.Tools},
Origin: &catalog.Origin{Repo: repo.ID, Revision: repo.Revision, Files: originFiles(set)},
}
if req.GuardFormat != "" {
+31
View File
@@ -0,0 +1,31 @@
package modelserve
import (
"slices"
"testing"
)
func TestLlamaCppIsGivenTheSlotsTheCatalogPromises(t *testing.T) {
// A 32768 pool for 32768-token requests is one slot: the engine's own
// four made slots of 8192, and requests the gateway admitted were refused.
for _, c := range []struct {
pool, ctx int
want []string
}{
{32768, 32768, []string{"--ctx-size", "32768", "--parallel", "1"}},
{16384, 4096, []string{"--ctx-size", "16384", "--parallel", "4"}},
{65536, 16384, []string{"--ctx-size", "65536", "--parallel", "4"}},
} {
if got := llamaArgs(Request{CtxPool: c.pool, ContextLength: c.ctx}); !slices.Equal(got, c.want) {
t.Errorf("pool %d, context %d: %v", c.pool, c.ctx, got)
}
}
}
func TestTheLongestAnswerGrowsWithTheContext(t *testing.T) {
for ctx, want := range map[int]int{2048: 1024, 4096: 1024, 16384: 4096, 32768: 8192, 131072: 8192} {
if got := maxOutput(ctx); got != want {
t.Errorf("context %d: max output %d, want %d", ctx, got, want)
}
}
}
+4
View File
@@ -208,6 +208,10 @@ func (r *Router) Handler() http.Handler {
// So the example on the profile page names a model that is actually
// running, rather than a placeholder that gets sent as-is.
pages.ServedModels(r.exampleModels)
pages.SetModelFacts(func(name string) web.ModelFacts {
_, m := r.resolve(name)
return web.ModelFacts{ContextLength: m.ContextLength, MaxOutput: m.MaxOutput, Tools: m.Capabilities.Tools}
})
pages.SetPersonalOff(r.personalOff)
pages.SetLlamaCppVersion(r.llamaVersion)
pages.SetPersonalDefaults(r.personalDefaults)
+5
View File
@@ -77,6 +77,8 @@ type AccountPages struct {
// field in that snippet is real, so the one placeholder left in it was
// copied verbatim and sent as the literal string MODEL.
served func() []string
// modelFacts is what the catalog says of a model, for an editor.
modelFacts func(model string) ModelFacts
// hasMirror is set when /admin/mirror is registered, so the other admin
// pages can link to it and only then.
hasMirror bool
@@ -132,6 +134,9 @@ type AccountPages struct {
// example falls back to a placeholder, which is what it always was.
func (p *AccountPages) ServedModels(f func() []string) { p.served = f }
// SetModelFacts supplies what an editor's configuration says of a model.
func (p *AccountPages) SetModelFacts(f func(model string) ModelFacts) { p.modelFacts = f }
// exampleModel picks the model the copy-pasteable example should name.
//
// A real model over a simulated one is not expressible here — this package does
+60 -12
View File
@@ -45,10 +45,25 @@ type connectField struct{ Label, Value string }
// unquoted: the names this gateway serves, and nothing a shell reads twice.
var safeModel = regexp.MustCompile(`^[A-Za-z0-9._:/@+-]{1,200}$`)
// ModelFacts is what an editor's configuration needs to know of a model: how
// much one request may hold, and whether its tool calls come back as calls.
type ModelFacts struct {
ContextLength int
// MaxOutput is the longest answer the gateway allows; 0 is no limit.
MaxOutput int
Tools bool
}
// continueYAML is a Continue assistant pointing at the gateway: a file of its
// own under ~/.continue/assistants, so the person's config.yaml is not touched.
func continueYAML(base, model, key string) string {
return fmt.Sprintf(`# Written by llmgw for Continue: the gateway at %[1]s.
//
// What it says of the model is what the catalog says. Continue assumed a
// context far larger than a CPU replica's, and every request over it was
// refused; and told a model uses tools when its calls come back as text,
// Continue's agent stalls on them rather than acting.
func continueYAML(base, model, key string, f ModelFacts) string {
var b strings.Builder
fmt.Fprintf(&b, `# Written by llmgw for Continue: the gateway at %[1]s.
name: llmgw
version: 1.0.0
schema: v1
@@ -62,11 +77,25 @@ models:
- chat
- edit
- apply
capabilities:
- tool_use
# For completion as you type, add a small, fast model here with the role
# autocomplete; a large one makes every keystroke wait for it.
`, base, model, key)
if f.Tools {
b.WriteString(" capabilities:\n - tool_use\n")
}
if f.ContextLength > 0 {
fmt.Fprintf(&b, ` # What the gateway lets one request hold; Continue trims to fit.
defaultCompletionOptions:
contextLength: %d
maxTokens: %d
`, f.ContextLength, maxTokensFor(f))
}
if !f.Tools {
b.WriteString("# This model's catalog entry does not say it makes tool calls, so Continue\n" +
"# chats and edits with it but its agent cannot act: choose one that does.\n")
}
b.WriteString(`# For completion as you type, add a small, fast model here with the role
# autocomplete; a large one makes every keystroke wait for it.
`)
return b.String()
}
// codexTOML is the provider and profile Codex needs, and nothing else of its
@@ -87,13 +116,13 @@ model = "%[2]s"
}
// connectFor builds what the page shows for a tool, in English.
func connectFor(tool, base, model, key string) connectView {
return connectIn(english, tool, base, model, key)
func connectFor(tool, base, model, key string, f ModelFacts) connectView {
return connectIn(english, tool, base, model, key, f)
}
// connectIn builds what the page shows for a tool, its sentences in l. The
// files, commands and paths are the same in every language.
func connectIn(l *Lang, tool, base, model, key string) connectView {
func connectIn(l *Lang, tool, base, model, key string, f ModelFacts) connectView {
v := connectView{Tool: tool, Model: model, Key: key}
for _, t := range editorTools {
if t.Key == tool {
@@ -104,7 +133,7 @@ func connectIn(l *Lang, tool, base, model, key string) connectView {
switch tool {
case "continue":
v.Files = []connectFile{{Path: "~/.continue/assistants/llmgw.yaml", Name: "llmgw.yaml",
Body: continueYAML(base, model, key)}}
Body: continueYAML(base, model, key, f)}}
v.Command = fmt.Sprintf("curl -fsSL '%s' | LLMGW_KEY='%s' sh", script, key)
v.Run = l.T("connect.run.continue")
case "codex":
@@ -173,7 +202,7 @@ func (p *AccountPages) connect(w http.ResponseWriter, r *http.Request) {
return
}
p.log.Info("a user connected an editor", "user", user.ID, "tool", tool, "key", k.ID)
c := connectIn(l, tool, baseURL(r), model, secret)
c := connectIn(l, tool, baseURL(r), model, secret, p.facts(model))
p.render(w, r, sess, user, profileData{Connect: &c})
}
@@ -210,7 +239,7 @@ cat > "$file" <<'LLMGW'
tmp="$file.tmp"
sed "s|__LLMGW_KEY__|$LLMGW_KEY|" "$file" > "$tmp" && mv "$tmp" "$file"
echo "Wrote $file. In Continue, choose the llmgw assistant."
`, base, continueYAML(base, model, "__LLMGW_KEY__"))
`, base, continueYAML(base, model, "__LLMGW_KEY__", p.facts(model)))
case "codex":
body = fmt.Sprintf(`#!/bin/sh
# Connects Codex to the llmgw gateway at %[1]s.
@@ -242,3 +271,22 @@ echo "Run: LLMGW_API_KEY=<your key> codex --profile llmgw"
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(body))
}
// facts is what the catalog says of a model, when there is one to ask.
func (p *AccountPages) facts(model string) ModelFacts {
if p.modelFacts == nil {
return ModelFacts{}
}
return p.modelFacts(model)
}
// maxTokensFor is the answer length Continue asks for: a quarter of the
// context, at most 4096, and never over what the gateway allows — asked for
// more, it refuses the request.
func maxTokensFor(f ModelFacts) int {
n := min(4096, max(256, f.ContextLength/4))
if f.MaxOutput > 0 {
n = min(n, f.MaxOutput)
}
return n
}
+31 -3
View File
@@ -11,7 +11,7 @@ import (
)
func TestEachToolGetsItsConfigurationWithTheKey(t *testing.T) {
c := connectFor("continue", "https://gw.example", "qwen2.5-coder-7b", "sk-llmgw-abc")
c := connectFor("continue", "https://gw.example", "qwen2.5-coder-7b", "sk-llmgw-abc", ModelFacts{})
if !strings.Contains(c.Files[0].Body, "apiBase: https://gw.example/v1") || !strings.Contains(c.Files[0].Body, "apiKey: sk-llmgw-abc") ||
c.Files[0].Path != "~/.continue/assistants/llmgw.yaml" {
t.Errorf("continue = %+v", c.Files)
@@ -21,11 +21,11 @@ func TestEachToolGetsItsConfigurationWithTheKey(t *testing.T) {
if strings.Contains(strings.Split(c.Command, "|")[0], "sk-llmgw-abc") {
t.Errorf("the key is in the URL: %s", c.Command)
}
x := connectFor("codex", "https://gw.example", "qwen2.5-coder-7b", "sk-llmgw-abc")
x := connectFor("codex", "https://gw.example", "qwen2.5-coder-7b", "sk-llmgw-abc", ModelFacts{})
if strings.Contains(x.Files[0].Body, "sk-llmgw-abc") || !strings.Contains(x.Files[0].Body, `env_key = "LLMGW_API_KEY"`) {
t.Errorf("codex writes the key into its file: %s", x.Files[0].Body)
}
cl := connectFor("cline", "https://gw.example", "m", "sk-llmgw-abc")
cl := connectFor("cline", "https://gw.example", "m", "sk-llmgw-abc", ModelFacts{})
if len(cl.Fields) != 4 || cl.Fields[1].Value != "https://gw.example/v1" || cl.Command != "" {
t.Errorf("cline = %+v", cl)
}
@@ -115,3 +115,31 @@ func TestASetupScriptRefusesANameAShellWouldRead(t *testing.T) {
t.Errorf("%d", w.Code)
}
}
func TestContinueIsToldTheModelsContextAndWhetherItCanUseTools(t *testing.T) {
// Told a context far larger than the gateway allows, Continue sends
// prompts that are refused; told a model uses tools when its calls come
// back as text, its agent stalls.
agent := continueYAML("https://gw.example", "coder-30b", "k", ModelFacts{ContextLength: 32768, Tools: true})
for _, want := range []string{"tool_use", "contextLength: 32768", "maxTokens: 4096"} {
if !strings.Contains(agent, want) {
t.Errorf("%q missing:\n%s", want, agent)
}
}
chat := continueYAML("https://gw.example", "coder", "k", ModelFacts{ContextLength: 4096})
if strings.Contains(chat, "tool_use") || !strings.Contains(chat, "contextLength: 4096") ||
!strings.Contains(chat, "maxTokens: 1024") || !strings.Contains(chat, "its agent cannot act") {
t.Errorf("a model without tools:\n%s", chat)
}
if unknown := continueYAML("https://gw.example", "m", "k", ModelFacts{}); strings.Contains(unknown, "contextLength") {
t.Errorf("a context nobody knows was written down:\n%s", unknown)
}
}
func TestContinueNeverAsksForMoreThanTheGatewayAllows(t *testing.T) {
// Asked for more than max_output, the gateway refuses the request.
y := continueYAML("https://gw.example", "m", "k", ModelFacts{ContextLength: 32768, MaxOutput: 2048, Tools: true})
if !strings.Contains(y, "maxTokens: 2048") {
t.Errorf("not capped at max_output:\n%s", y)
}
}
+3 -3
View File
@@ -171,18 +171,18 @@ func TestTheProfilePageShowsTurkishNumbersAndDates(t *testing.T) {
func TestTheEditorSetupExplainsItselfInTurkish(t *testing.T) {
tr := langByCode("tr")
c := connectIn(tr, "cline", "https://gw.example", "m", "sk-llmgw-abc")
c := connectIn(tr, "cline", "https://gw.example", "m", "sk-llmgw-abc", ModelFacts{})
if c.Fields[2].Label != "API anahtarı (API Key)" || c.Fields[0].Value != "OpenAI Compatible" ||
!strings.Contains(c.Run, "dişli simgesine") {
t.Errorf("cline in Turkish = %+v", c)
}
x := connectIn(tr, "codex", "https://gw.example", "m", "sk-llmgw-abc")
x := connectIn(tr, "codex", "https://gw.example", "m", "sk-llmgw-abc", ModelFacts{})
// What is typed or written to a file stays what the tool reads.
if x.Run != "LLMGW_API_KEY='sk-llmgw-abc' codex --profile llmgw" || !strings.Contains(x.Files[0].Body, "[profiles.llmgw]") ||
!strings.Contains(x.Notes[0], "~/.codex/config.toml") {
t.Errorf("codex in Turkish = %+v", x)
}
if en := connectFor("continue", "https://gw.example", "m", "k"); en.Run != `In Continue, choose the "llmgw" assistant at the top of its panel.` {
if en := connectFor("continue", "https://gw.example", "m", "k", ModelFacts{}); en.Run != `In Continue, choose the "llmgw" assistant at the top of its panel.` {
t.Errorf("English Continue: %q", en.Run)
}
}