Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3558dba485 | ||
|
|
481523ca46 | ||
|
|
df6d243ee7 | ||
|
|
ef33e52d3d |
No files matched your search
+17
-1
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user