Compare commits

...

4 Commits

Author SHA1 Message Date
localai-org-maint-bot
053e828484 fix(gallery): use tokenizer templates for Gemma
Let the model-provided tokenizer template format Gemma conversations instead of maintaining a shared inline prompt template.

Assisted-by: Codex:gpt-5
2026-07-29 14:02:20 +00:00
localai-org-maint-bot
c905d40d9e fix(reasoning): ignore preclosed prompt markers
Do not seed streaming reasoning state when the latest prompt thinking marker is already followed by its matching closing marker. This keeps direct Gemma 4 output in content when its template disables thinking with a preclosed channel.

Assisted-by: Codex:gpt-5
2026-07-29 14:02:20 +00:00
mudler's LocalAI [bot]
8089b2bf09 fix(ci): only rebuild the full backend matrix on breaking backend.proto edits (#11192)
backend/backend.proto is consumed by every language, so its SHARED_BUILD_INPUTS
rule could only ever be always/always: 417 Linux plus 56 Darwin builds. It fires
on ~1.3% of commits (10 of 767 over six months), which made it the single
largest CI cost driver in the repo.

On 2026-07-29 the queue reached 2178 jobs against 8 concurrent runners. Four
runs totalling 935 of those jobs were triggered by nothing but a proto edit. The
largest, 378 jobs on master, came from PR #11158, whose entire proto diff was
six lines adding `bool cache_prompt = 8;` to one message. No backend that does
not read that field behaves any differently for it.

Make the rule content-aware. changed-backends.js resolves backend.proto at the
base revision (the contents-API pattern already used for backend-matrix.yml) and
hands both texts to protoChangeIsAdditive(), which compares them structurally so
a comment reflow, reindent or field reorder does not read as a change. An
additive-only edit (new field with an unused number, new message, new enum
value, new RPC) suppresses the rule and rebuilds nothing; a removed, renumbered,
retyped or renamed field, a dropped RPC or a changed option still rebuilds
everything, as does an unresolvable base revision.

Every other matched rule is untouched, so a PR that edits the proto and
scripts/build/ is still a full rebuild, and the weekly full-matrix cron remains
the backstop for stale wheels.

Verified against all ten proto commits of the preceding six months: the nine
with a resolvable parent all classify as additive, and controls covering a
retyped-and-renumbered field, a deleted RPC, identical revisions and a
reindent-plus-comment-reflow all classify correctly.


Assisted-by: Claude:opus-5 [claude-code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-29 15:58:27 +02:00
localai-org-maint-bot
89ee62b2af gallery: add KAT-Coder V2.5 Dev GGUF variants (#11186)
* gallery: add KAT-Coder V2.5 Dev GGUF variants

Add Q4_K_M and Q8_0 builds of the newly released KAT-Coder-V2.5-Dev agentic coding model.

Assisted-by: Codex:gpt-5 [Hugging Face API]

* gallery: add KAT-Coder APEX variants

Assisted-by: Codex:gpt-5 [web]

* gallery: add KAT-Coder APEX checksums

Assisted-by: Codex:gpt-5

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-07-29 15:35:51 +02:00
13 changed files with 632 additions and 35 deletions

View File

@@ -122,7 +122,7 @@ The per-backend prefix match only sees files under a backend's own directory, so
| Changed path | Rebuilds |
|---|---|
| `backend/backend.proto` | everything (all languages compile or copy it) |
| `backend/backend.proto` | nothing if the edit is additive-only, otherwise everything (see below) |
| `backend/Dockerfile.<x>` | the Linux entries whose `dockerfile:` names it |
| `backend/python/common/` | Python, Linux + Darwin |
| `scripts/build/package-gpu-libs.sh` | Python, Linux only |
@@ -132,6 +132,17 @@ The per-backend prefix match only sees files under a backend's own directory, so
Deliberately excluded: `backend/index.yaml` (gallery metadata, never enters an image), `.github/backend-matrix.yml` (adding a backend would rebuild all of them), `backend/Dockerfile.base-grpc-builder` (owned by `base-images.yml`), and the root `Makefile` (touched in ~11% of commits, and its backend-relevant edits arrive alongside the backend directory anyway). `make test-ci-scripts` pins all of this.
#### `backend/backend.proto` is content-filtered, not path-filtered
Every language consumes the proto, so a path rule for it can only ever say "rebuild all 473 images". It changes in ~1.3% of commits, and that was enough to make it the single largest CI cost driver in the repo: on 2026-07-29 four runs totalling 935 queued jobs traced to nothing but a proto edit, one of which (#11158) was a six-line diff adding `bool cache_prompt = 8;`.
An additive proto edit cannot change how a backend that never references the new symbol behaves, so `filterMatrix()` suppresses the rule for one. `changed-backends.js` fetches `backend/backend.proto` at the base revision (same contents-API pattern as `.github/backend-matrix.yml`) and hands both texts to `protoChangeIsAdditive()`, which compares them structurally rather than textually:
- **Additive, rebuilds nothing**: a new field with an unused number, a new message, a new enum value, a new RPC. Comment, whitespace and ordering changes also land here.
- **Breaking, rebuilds everything**: a removed, renumbered, retyped or renamed field, a dropped RPC, a changed `option` or `package`. So does an unresolvable base revision, matching the run-all posture used for a truncated diff.
Checked against every proto commit in the preceding six months, all nine resolvable ones classify as additive. Note the tradeoff this accepts: generated stubs do change for an additive edit, so image bytes would differ on a rebuild even though behavior does not. That is the same standard already applied when the filter declines to rebuild on unrelated `pkg/` changes, and the weekly cron remains the backstop.
The Sunday 06:00 UTC cron on `backend.yml` exists specifically because path filtering can leave Python backends frozen on stale wheels. `DEPS_REFRESH` (below) only fires when the build actually runs, so an untouched Python backend would never re-resolve its unpinned deps. The weekly cron is the safety net.
## The `DEPS_REFRESH` cache-buster (Python backends)

View File

@@ -696,6 +696,9 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
template = predInput
}
thinkingStartToken := reason.DetectThinkingStartToken(template, &config.ReasoningConfig)
if config.TemplateConfig.UseTokenizerTemplate {
thinkingStartToken = reason.DetectThinkingStartTokenInTemplate(template, &config.ReasoningConfig)
}
xlog.Debug("Thinking start token", "thinkingStartToken", thinkingStartToken, "template", template)

View File

@@ -150,6 +150,9 @@ func processStream(
template = s
}
thinkingStartToken := reason.DetectThinkingStartToken(template, &cfg.ReasoningConfig)
if cfg.TemplateConfig.UseTokenizerTemplate {
thinkingStartToken = reason.DetectThinkingStartTokenInTemplate(template, &cfg.ReasoningConfig)
}
extractor := reason.NewReasoningExtractor(thinkingStartToken, cfg.ReasoningConfig)
// preferAutoparser is sticky: once the C++ autoparser has ever classified
@@ -248,6 +251,9 @@ func processStreamWithTools(
template = prompt
}
thinkingStartToken := reason.DetectThinkingStartToken(template, &cfg.ReasoningConfig)
if cfg.TemplateConfig.UseTokenizerTemplate {
thinkingStartToken = reason.DetectThinkingStartTokenInTemplate(template, &cfg.ReasoningConfig)
}
extractor := reason.NewReasoningExtractor(thinkingStartToken, cfg.ReasoningConfig)
result := ""

View File

@@ -2480,6 +2480,9 @@ func triggerResponseAtTurn(ctx context.Context, session *Session, conv *Conversa
template = config.TemplateConfig.Chat
}
thinkingStartToken := reasoning.DetectThinkingStartToken(template, &config.ReasoningConfig)
if config.TemplateConfig.UseTokenizerTemplate {
thinkingStartToken = reasoning.DetectThinkingStartTokenInTemplate(template, &config.ReasoningConfig)
}
// When the C++ autoparser emitted ChatDeltas with actionable data,
// prefer them — the backend clears Reply.Message in that path and

View File

@@ -145,6 +145,9 @@ func streamLLMResponse(ctx context.Context, session *Session, conv *Conversation
template = llmCfg.TemplateConfig.Chat
}
thinkingStartToken := reasoning.DetectThinkingStartToken(template, &llmCfg.ReasoningConfig)
if llmCfg.TemplateConfig.UseTokenizerTemplate {
thinkingStartToken = reasoning.DetectThinkingStartTokenInTemplate(template, &llmCfg.ReasoningConfig)
}
// The autoparser (tokenizer-template path) already delivers reasoning-free
// content. Prefilling the thinking start token here would re-tag that clean

View File

@@ -1359,6 +1359,9 @@ func handleOpenResponsesNonStream(c echo.Context, responseID string, createdAt i
template = predInput
}
thinkingStartToken := reason.DetectThinkingStartToken(template, &cfg.ReasoningConfig)
if cfg.TemplateConfig.UseTokenizerTemplate {
thinkingStartToken = reason.DetectThinkingStartTokenInTemplate(template, &cfg.ReasoningConfig)
}
// Extract reasoning from result before cleaning
reasoningContent, cleanedResult := reason.ExtractReasoningComplete(result, thinkingStartToken, cfg.ReasoningConfig)
@@ -1640,6 +1643,9 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6
template = predInput
}
thinkingStartToken := reason.DetectThinkingStartToken(template, &cfg.ReasoningConfig)
if cfg.TemplateConfig.UseTokenizerTemplate {
thinkingStartToken = reason.DetectThinkingStartTokenInTemplate(template, &cfg.ReasoningConfig)
}
// Track state for streaming
var currentMessageID string

View File

@@ -10,33 +10,5 @@ config_file: |
- <end_of_turn>
- <start_of_turn>
template:
chat: |
{{.Input }}
<start_of_turn>model
chat_message: |-
<start_of_turn>{{if eq .RoleName "assistant" }}model{{else}}{{ .RoleName }}{{end}}
{{ if .FunctionCall -}}
{{ else if eq .RoleName "tool" -}}
{{ end -}}
{{ if .Content -}}
{{.Content -}}
{{ end -}}
{{ if .FunctionCall -}}
{{toJson .FunctionCall}}
{{ end -}}<end_of_turn>
completion: |
{{.Input}}
function: |
<start_of_turn>system
You have access to functions. If you decide to invoke any of the function(s),
you MUST put it in the format of
{"name": function name, "parameters": dictionary of argument name and its value}
You SHOULD NOT include any other text in the response if you call a function
{{range .Functions}}
{'type': 'function', 'function': {'name': '{{.Name}}', 'description': '{{.Description}}', 'parameters': {{toJson .Parameters}} }}
{{end}}
<end_of_turn>
{{.Input -}}
<start_of_turn>model
use_tokenizer_template: true
name: gemma

View File

@@ -1,4 +1,230 @@
---
- &kat-coder-v2-5-dev
name: "kat-coder-v2.5-dev"
variants:
- model: kat-coder-v2.5-dev-q8
- model: kat-coder-v2.5-dev-apex-i-quality
- model: kat-coder-v2.5-dev-apex-i-balanced
- model: kat-coder-v2.5-dev-apex-i-compact
- model: kat-coder-v2.5-dev-apex-i-mini
- model: kat-coder-v2.5-dev-apex-quality
- model: kat-coder-v2.5-dev-apex-balanced
- model: kat-coder-v2.5-dev-apex-compact
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:
- https://huggingface.co/Kwaipilot/KAT-Coder-V2.5-Dev
- https://huggingface.co/bartowski/Kwaipilot_KAT-Coder-V2.5-Dev-GGUF
- https://huggingface.co/mudler/KAT-Coder-V2.5-Dev-APEX-GGUF
description: |
KAT-Coder-V2.5-Dev is an Apache-2.0 agentic coding model from Kwaipilot,
post-trained from Qwen3.6-35B-A3B. It has 35 billion total parameters with
3 billion activated per token, a 262K-token context window, and text-only
weights tuned for repository-level coding and tool use.
This entry offers standard Q4_K_M and Q8_0 GGUF quantizations alongside
APEX mixed-precision variants with quality, balanced, compact, and mini
profiles. The APEX I-profiles use importance-matrix calibration.
license: "apache-2.0"
icon: https://huggingface.co/Kwaipilot/KAT-Coder-V2.5-Dev/resolve/main/kat_logo_hd.png
tags:
- llm
- gguf
- qwen
- coding
last_checked: "2026-07-29"
overrides:
backend: llama-cpp
function:
automatic_tool_parsing_fallback: true
grammar:
disable: true
known_usecases:
- chat
options:
- use_jinja:true
parameters:
model: llama-cpp/models/Kwaipilot_KAT-Coder-V2.5-Dev-GGUF/Kwaipilot_KAT-Coder-V2.5-Dev-Q4_K_M.gguf
template:
use_tokenizer_template: true
files:
- filename: llama-cpp/models/Kwaipilot_KAT-Coder-V2.5-Dev-GGUF/Kwaipilot_KAT-Coder-V2.5-Dev-Q4_K_M.gguf
sha256: 4221c26e5663502d1c96fc901c9967d0e70ce2dcfaa5a9fb9280a46bd19e3c07
uri: huggingface://bartowski/Kwaipilot_KAT-Coder-V2.5-Dev-GGUF/Kwaipilot_KAT-Coder-V2.5-Dev-Q4_K_M.gguf
- !!merge <<: *kat-coder-v2-5-dev
name: "kat-coder-v2.5-dev-q8"
variants: []
description: |
KAT-Coder-V2.5-Dev is an Apache-2.0 agentic coding model from Kwaipilot,
post-trained from Qwen3.6-35B-A3B. It has 35 billion total parameters with
3 billion activated per token, a 262K-token context window, and text-only
weights tuned for repository-level coding and tool use.
This entry uses the higher-quality Q8_0 GGUF quantization.
overrides:
backend: llama-cpp
function:
automatic_tool_parsing_fallback: true
grammar:
disable: true
known_usecases:
- chat
options:
- use_jinja:true
parameters:
model: llama-cpp/models/Kwaipilot_KAT-Coder-V2.5-Dev-GGUF/Kwaipilot_KAT-Coder-V2.5-Dev-Q8_0.gguf
template:
use_tokenizer_template: true
files:
- filename: llama-cpp/models/Kwaipilot_KAT-Coder-V2.5-Dev-GGUF/Kwaipilot_KAT-Coder-V2.5-Dev-Q8_0.gguf
sha256: 5fa510f44779b0e3d38a6678985f417a1c65e3000405ca5d6dcf7fd065e47a15
uri: huggingface://bartowski/Kwaipilot_KAT-Coder-V2.5-Dev-GGUF/Kwaipilot_KAT-Coder-V2.5-Dev-Q8_0.gguf
- !!merge <<: *kat-coder-v2-5-dev
name: "kat-coder-v2.5-dev-apex-i-quality"
variants: []
overrides:
backend: llama-cpp
function:
automatic_tool_parsing_fallback: true
grammar:
disable: true
known_usecases:
- chat
options:
- use_jinja:true
parameters:
model: llama-cpp/models/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-I-Quality.gguf
template:
use_tokenizer_template: true
files:
- filename: llama-cpp/models/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-I-Quality.gguf
sha256: e1cf7f33e13ee787a8557effee41eef5261b24696e283ef9d320830ba39f6784
uri: huggingface://mudler/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-I-Quality.gguf
- !!merge <<: *kat-coder-v2-5-dev
name: "kat-coder-v2.5-dev-apex-i-balanced"
variants: []
overrides:
backend: llama-cpp
function:
automatic_tool_parsing_fallback: true
grammar:
disable: true
known_usecases:
- chat
options:
- use_jinja:true
parameters:
model: llama-cpp/models/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-I-Balanced.gguf
template:
use_tokenizer_template: true
files:
- filename: llama-cpp/models/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-I-Balanced.gguf
sha256: ee6e0ec15964c42ba91831d13e9709239f1b74da3dc49dd3edec4ad6aed8029f
uri: huggingface://mudler/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-I-Balanced.gguf
- !!merge <<: *kat-coder-v2-5-dev
name: "kat-coder-v2.5-dev-apex-i-compact"
variants: []
overrides:
backend: llama-cpp
function:
automatic_tool_parsing_fallback: true
grammar:
disable: true
known_usecases:
- chat
options:
- use_jinja:true
parameters:
model: llama-cpp/models/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-I-Compact.gguf
template:
use_tokenizer_template: true
files:
- filename: llama-cpp/models/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-I-Compact.gguf
sha256: 5235ac39e7989d9fcf08078acfa755611d42a1f80060d226e4aba04a3595d0d8
uri: huggingface://mudler/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-I-Compact.gguf
- !!merge <<: *kat-coder-v2-5-dev
name: "kat-coder-v2.5-dev-apex-i-mini"
variants: []
overrides:
backend: llama-cpp
function:
automatic_tool_parsing_fallback: true
grammar:
disable: true
known_usecases:
- chat
options:
- use_jinja:true
parameters:
model: llama-cpp/models/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-I-Mini.gguf
template:
use_tokenizer_template: true
files:
- filename: llama-cpp/models/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-I-Mini.gguf
sha256: 9d901bf1c44946840e15e6f73a66782f56ae2f1a41d6508995ddcd976e9af878
uri: huggingface://mudler/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-I-Mini.gguf
- !!merge <<: *kat-coder-v2-5-dev
name: "kat-coder-v2.5-dev-apex-quality"
variants: []
overrides:
backend: llama-cpp
function:
automatic_tool_parsing_fallback: true
grammar:
disable: true
known_usecases:
- chat
options:
- use_jinja:true
parameters:
model: llama-cpp/models/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-Quality.gguf
template:
use_tokenizer_template: true
files:
- filename: llama-cpp/models/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-Quality.gguf
sha256: 849737baf59183ed518d34f2460f0dcd86190953ccc3213072bdcb1d7f8d2882
uri: huggingface://mudler/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-Quality.gguf
- !!merge <<: *kat-coder-v2-5-dev
name: "kat-coder-v2.5-dev-apex-balanced"
variants: []
overrides:
backend: llama-cpp
function:
automatic_tool_parsing_fallback: true
grammar:
disable: true
known_usecases:
- chat
options:
- use_jinja:true
parameters:
model: llama-cpp/models/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-Balanced.gguf
template:
use_tokenizer_template: true
files:
- filename: llama-cpp/models/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-Balanced.gguf
sha256: 9a5d31b110a95bc085d9851c420dd7a1ffeaec7c9263073c8850d8fb81c4f8ba
uri: huggingface://mudler/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-Balanced.gguf
- !!merge <<: *kat-coder-v2-5-dev
name: "kat-coder-v2.5-dev-apex-compact"
variants: []
overrides:
backend: llama-cpp
function:
automatic_tool_parsing_fallback: true
grammar:
disable: true
known_usecases:
- chat
options:
- use_jinja:true
parameters:
model: llama-cpp/models/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-Compact.gguf
template:
use_tokenizer_template: true
files:
- filename: llama-cpp/models/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-Compact.gguf
sha256: 98dfee53102bbf01e67a768066543a707b40f365ff71e35132ac432c58ad99fd
uri: huggingface://mudler/KAT-Coder-V2.5-Dev-APEX-GGUF/KAT-Coder-V2.5-Dev-APEX-Compact.gguf
- name: "inkling"
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:

View File

@@ -21,6 +21,17 @@ import (
// - [THINK] (Magistral models)
// Custom tokens from config are checked first, then default tokens.
func DetectThinkingStartToken(prompt string, config *Config) string {
return detectThinkingStartToken(prompt, config, true)
}
// DetectThinkingStartTokenInTemplate detects a possible prefill in an
// unrendered tokenizer template. Marker ordering cannot reveal which Jinja
// branch will render, so matching closing markers are intentionally ignored.
func DetectThinkingStartTokenInTemplate(template string, config *Config) string {
return detectThinkingStartToken(template, config, false)
}
func detectThinkingStartToken(prompt string, config *Config, honorClosingToken bool) string {
// Common thinking start tokens (in order of specificity - longer first)
// Based on llama.cpp's chat-parser.cpp implementations
defaultTokens := []string{
@@ -44,7 +55,8 @@ func DetectThinkingStartToken(prompt string, config *Config) string {
// Check if prompt ends with any of these tokens (allowing for trailing whitespace/newlines)
trimmedPrompt := strings.TrimRight(prompt, " \t\n\r")
for _, token := range thinkingStartTokens {
if strings.Contains(trimmedPrompt, token) {
if strings.Contains(trimmedPrompt, token) &&
(!honorClosingToken || !thinkingTokenClosedAfterLastStart(trimmedPrompt, token, config)) {
return token
}
}
@@ -67,6 +79,15 @@ func DetectThinkingStartToken(prompt string, config *Config) string {
return ""
}
func thinkingTokenClosedAfterLastStart(prompt, startToken string, config *Config) bool {
endToken := ClosingTokenForStart(startToken, config)
if endToken == "" {
return false
}
return strings.LastIndex(prompt, endToken) > strings.LastIndex(prompt, startToken)
}
// ExtractReasoningWithConfig extracts reasoning from content with the given config.
// If reasoning is disabled, it returns the original content.
// If thinking start token prefill is enabled, it prepends the thinking start token to the content.

View File

@@ -410,6 +410,29 @@ var _ = Describe("DetectThinkingStartToken", func() {
token := DetectThinkingStartToken(prompt, nil)
Expect(token).To(Equal("<think>"))
})
It("should ignore a Gemma thinking token that is already closed in the prompt", func() {
prompt := "<|turn>model\n<|channel>thought\n<channel|>\n"
token := DetectThinkingStartToken(prompt, nil)
Expect(token).To(BeEmpty())
extractor := NewReasoningExtractor(token, Config{})
reasoningDelta, contentDelta := extractor.ProcessToken("READY.")
Expect(reasoningDelta).To(BeEmpty())
Expect(contentDelta).To(Equal("READY."))
})
It("should preserve prefill detection for unrendered conditional templates", func() {
template := "{% if enable_thinking %}<think>{% else %}<think></think>{% endif %}"
token := DetectThinkingStartTokenInTemplate(template, nil)
Expect(token).To(Equal("<think>"))
})
It("should ignore user Jinja text before a preclosed Gemma prompt suffix", func() {
prompt := "Explain {{ variable }}\n<|turn>model\n<|channel>thought\n<channel|>\n"
token := DetectThinkingStartToken(prompt, nil)
Expect(token).To(BeEmpty())
})
})
Context("when prompt does not contain thinking tokens", func() {

View File

@@ -6,6 +6,7 @@ import {
getAllBackendPaths,
filterMatrix,
BACKEND_MATRIX_FILE,
BACKEND_PROTO_FILE,
} from "./lib/backend-filter.mjs";
// Matrix data lives in a small data-only YAML so both backend.yml (master push)
@@ -116,6 +117,42 @@ async function getPreviousMatrix(event) {
}
}
// backend.proto at the base revision plus the checked-out copy, so filterMatrix
// can tell an additive edit (a new field, message or RPC, which invalidates no
// existing image) from a breaking one. Returning null means "rebuild
// everything", the same posture as an unresolvable matrix diff.
//
// Only called when the changed-file list names the proto, so the common path
// costs no extra API request.
async function getProtoRevisions(event) {
const ref = event.pull_request ? event.pull_request.base.sha : event.before;
if (!ref || /^0+$/.test(ref)) return null;
const owner = event.repository.owner.login;
const repo = event.repository.name;
try {
const res = await octokit.request('GET /repos/{owner}/{repo}/contents/{path}', {
owner,
repo,
path: BACKEND_PROTO_FILE,
ref,
mediaType: { format: 'raw' },
});
const previous = typeof res.data === 'string'
? res.data
: Buffer.from(res.data.content, 'base64').toString('utf8');
return {
previous,
current: fs.readFileSync(BACKEND_PROTO_FILE, "utf8"),
};
} catch (err) {
console.log(
`could not read ${BACKEND_PROTO_FILE} at ${ref}, falling back to run-all:`,
err.message
);
return null;
}
}
// Group matrix entries by tag-suffix and emit a merge-matrix entry per group.
// Both multi-leg groups (per-arch fan-out) and singletons get one entry each:
// the build job pushes by digest only with no tags applied, so every backend
@@ -240,7 +277,7 @@ function emitFullMatrix() {
}
}
function emitFilteredMatrix(changedFiles, previousMatrix) {
function emitFilteredMatrix(changedFiles, previousMatrix, protoRevisions) {
console.log("Changed files:", changedFiles);
const { filtered, filteredDarwin, changedBackends } = filterMatrix({
@@ -248,6 +285,7 @@ function emitFilteredMatrix(changedFiles, previousMatrix) {
includesDarwin,
changedFiles,
previousMatrix,
protoRevisions,
});
console.log("Filtered files:", filtered);
@@ -306,5 +344,9 @@ function emitFilteredMatrix(changedFiles, previousMatrix) {
? await getPreviousMatrix(event)
: null;
emitFilteredMatrix(changedFiles, previousMatrix);
const protoRevisions = changedFiles.includes(BACKEND_PROTO_FILE)
? await getProtoRevisions(event)
: null;
emitFilteredMatrix(changedFiles, previousMatrix, protoRevisions);
})();

View File

@@ -178,6 +178,126 @@ const GO_BACKEND_PKG_PREFIXES = [
"pkg/utils/",
];
export const BACKEND_PROTO_FILE = "backend/backend.proto";
const PROTO_RULE_ID = "backend-proto";
// Split a .proto into a map of symbol -> fingerprint so two revisions can be
// compared structurally instead of textually. A comment reflow, a reindent or a
// reordered field must not read as a change, and a renumbered or retyped field
// must.
//
// The scanner is deliberately syntax-light: it tracks brace depth to build a
// container path and records one entry per declaration (`message`, `enum`,
// `service`, `oneof`, `rpc`) and one per statement (fields, enum values,
// `option`, `reserved`). It never needs to understand types, only to notice
// when the text describing one stops being identical.
function protoSymbols(text) {
const symbols = new Map();
const stack = [];
const norm = s => s.trim().replace(/\s+/g, " ");
// A declaration is identified by its kind and name, so that changing its body
// shows up as changed members rather than as a wholesale replacement.
const declKey = header => {
const rpc = header.match(/^rpc\s+([A-Za-z_]\w*)/);
if (rpc) return `rpc ${rpc[1]}`;
const decl = header.match(/^(message|enum|service|oneof|extend)\s+([A-Za-z_]\w*)/);
if (decl) return `${decl[1]} ${decl[2]}`;
return header;
};
// `bool cache_prompt = 8` is identified by `cache_prompt`, so renumbering or
// retyping it changes the fingerprint under a stable key, while renaming it
// reads as a removal plus an addition. Statements with no `=` (`reserved 4;`)
// are their own identity.
const stmtKey = stmt => {
const eq = stmt.indexOf("=");
if (eq === -1) return stmt;
const lhs = norm(stmt.slice(0, eq)).split(" ");
return lhs[lhs.length - 1] || stmt;
};
let buf = "";
let i = 0;
while (i < text.length) {
const c = text[i];
// String literals first: `option go_package = "github.com/..."` contains a
// `//` that is not a comment.
if (c === '"' || c === "'") {
buf += c;
i++;
while (i < text.length) {
if (text[i] === "\\") {
buf += text.slice(i, i + 2);
i += 2;
continue;
}
buf += text[i];
i++;
if (text[i - 1] === c) break;
}
continue;
}
if (c === "/" && text[i + 1] === "/") {
while (i < text.length && text[i] !== "\n") i++;
continue;
}
if (c === "/" && text[i + 1] === "*") {
i += 2;
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
i += 2;
continue;
}
if (c === "{") {
const header = norm(buf);
buf = "";
i++;
const key = header ? declKey(header) : "";
if (header) symbols.set(`${stack.join("/")}|${key}`, header);
stack.push(key);
continue;
}
if (c === "}") {
stack.pop();
buf = "";
i++;
continue;
}
if (c === ";") {
const stmt = norm(buf);
buf = "";
i++;
if (stmt) symbols.set(`${stack.join("/")}|${stmtKey(stmt)}`, stmt);
continue;
}
buf += c;
i++;
}
return symbols;
}
// True when every symbol the previous revision declared survives unchanged into
// the current one. New symbols are free: a backend that never references a new
// field, message or RPC produces the same behavior with or without it, so its
// image does not need rebuilding.
//
// Anything else (a removed, renumbered, retyped or renamed field, a dropped
// RPC, a changed option) is treated as breaking and rebuilds the full matrix.
// Either text being unresolvable is breaking too, matching the run-all posture
// changed-backends.js takes for a diff it cannot compute.
export function protoChangeIsAdditive(previousText, currentText) {
if (typeof previousText !== "string" || typeof currentText !== "string") {
return false;
}
const before = protoSymbols(previousText);
const after = protoSymbols(currentText);
for (const [key, fingerprint] of before) {
if (after.get(key) !== fingerprint) return false;
}
return true;
}
// Shared build inputs: files that end up in, or decide the contents of, images
// belonging to backends whose own directory they do not live under. The
// per-backend prefix match in filterMatrix() structurally cannot see these, so
@@ -200,7 +320,14 @@ export const SHARED_BUILD_INPUTS = [
// backends regenerate their stubs from it via `make protogen-go`, the C++
// CMakeLists compile backend.pb.cc from it, and the Rust crate Makefile
// copies it in before build.rs runs.
matches: file => file === "backend/backend.proto",
//
// Which is why this rule is always/always, and why it is the single most
// expensive entry in the table: 417 Linux plus 56 Darwin builds. It fires
// on 1.3% of commits (10 of 767 over six months), and every one of those
// ten was purely additive. filterMatrix() suppresses this rule for an
// additive-only diff, so `id` exists to identify it there.
id: PROTO_RULE_ID,
matches: file => file === BACKEND_PROTO_FILE,
linux: always,
darwin: always,
},
@@ -362,8 +489,19 @@ export function filterMatrix({
includesDarwin,
changedFiles,
previousMatrix,
protoRevisions,
}) {
const sharedRules = matchedSharedRules(changedFiles);
// An additive-only backend.proto edit invalidates no existing image, so drop
// its always/always rule. Every other matched rule still applies: a PR that
// touches the proto and scripts/build/ is still a full rebuild.
const protoAdditiveOnly =
changedFiles.includes(BACKEND_PROTO_FILE) &&
!!protoRevisions &&
protoChangeIsAdditive(protoRevisions.previous, protoRevisions.current);
const sharedRules = matchedSharedRules(changedFiles).filter(
rule => !(rule.id === PROTO_RULE_ID && protoAdditiveOnly)
);
const matrixFileChanged = changedFiles.includes(BACKEND_MATRIX_FILE);
// The matrix file changed but we could not resolve what it used to say (API

View File

@@ -385,3 +385,146 @@ test("an unavailable previous matrix conservatively rebuilds everything", () =>
assert.equal(filtered.length, includes.length);
assert.equal(filteredDarwin.length, includesDarwin.length);
});
// --- backend.proto: additive changes must not rebuild the world -------------
//
// backend/backend.proto is consumed by every language, so the SHARED_BUILD_INPUTS
// rule for it is always/always: a full 417-entry Linux matrix plus all 56 Darwin
// entries. It fires on 1.3% of commits, and in practice every one of those has
// been purely additive (a new field with an unused number, a new message, a new
// RPC). Adding `bool cache_prompt = 8;` (PR #11158) cannot change the behavior of
// a backend that never reads it, yet it rebuilt all 473 images.
//
// So the rule becomes content-aware rather than path-aware, using the same shape
// as previousMatrix above: changed-backends.js resolves the base revision and
// hands the two texts in, and everything here stays pure.
const protoWith = body => `
syntax = "proto3";
package backend;
service Backend {
rpc Health(HealthMessage) returns (Reply) {}
rpc Predict(PredictOptions) returns (Reply) {}
}
message HealthMessage {}
message PredictOptions {
${body}
}
`;
const BASE_FIELDS = ` string Prompt = 1;
int32 Tokens = 2;
bool UseTokenizerTemplate = 3;`;
const runProto = (previous, current) =>
filterMatrix({
includes,
includesDarwin,
changedFiles: ["backend/backend.proto"],
protoRevisions: { previous, current },
});
test("an added proto field rebuilds nothing", () => {
// The real PR #11158 diff: six added lines, one new field, unused number.
const { filtered, filteredDarwin, changedBackends } = runProto(
protoWith(BASE_FIELDS),
protoWith(`${BASE_FIELDS}\n bool cache_prompt = 8;`)
);
assert.deepEqual(filtered, []);
assert.deepEqual(filteredDarwin, []);
assert.equal(changedBackends.size, 0);
});
test("an added proto message and RPC rebuild nothing", () => {
const current = protoWith(BASE_FIELDS).replace(
"message HealthMessage {}",
"message HealthMessage {}\n\nmessage ScoreRequest {\n string Text = 1;\n}"
).replace(
" rpc Predict(PredictOptions) returns (Reply) {}",
" rpc Predict(PredictOptions) returns (Reply) {}\n rpc Score(ScoreRequest) returns (Reply) {}"
);
const { filtered, filteredDarwin } = runProto(protoWith(BASE_FIELDS), current);
assert.deepEqual(filtered, []);
assert.deepEqual(filteredDarwin, []);
});
test("a removed proto field rebuilds every backend on every OS", () => {
const { filtered, filteredDarwin } = runProto(
protoWith(BASE_FIELDS),
protoWith(` string Prompt = 1;\n bool UseTokenizerTemplate = 3;`)
);
assert.equal(filtered.length, includes.length);
assert.equal(filteredDarwin.length, includesDarwin.length);
});
test("a renumbered proto field rebuilds every backend on every OS", () => {
// Wire-incompatible: an old backend reading field 2 gets nothing.
const { filtered, filteredDarwin } = runProto(
protoWith(BASE_FIELDS),
protoWith(` string Prompt = 1;\n int32 Tokens = 9;\n bool UseTokenizerTemplate = 3;`)
);
assert.equal(filtered.length, includes.length);
assert.equal(filteredDarwin.length, includesDarwin.length);
});
test("a retyped proto field rebuilds every backend on every OS", () => {
const { filtered, filteredDarwin } = runProto(
protoWith(BASE_FIELDS),
protoWith(` string Prompt = 1;\n int64 Tokens = 2;\n bool UseTokenizerTemplate = 3;`)
);
assert.equal(filtered.length, includes.length);
assert.equal(filteredDarwin.length, includesDarwin.length);
});
test("a renamed proto field rebuilds every backend on every OS", () => {
// Same number and type, but every generated accessor changes name.
const { filtered, filteredDarwin } = runProto(
protoWith(BASE_FIELDS),
protoWith(` string Prompt = 1;\n int32 MaxTokens = 2;\n bool UseTokenizerTemplate = 3;`)
);
assert.equal(filtered.length, includes.length);
assert.equal(filteredDarwin.length, includesDarwin.length);
});
test("a removed proto RPC rebuilds every backend on every OS", () => {
const { filtered, filteredDarwin } = runProto(
protoWith(BASE_FIELDS),
protoWith(BASE_FIELDS).replace(
" rpc Predict(PredictOptions) returns (Reply) {}\n",
""
)
);
assert.equal(filtered.length, includes.length);
assert.equal(filteredDarwin.length, includesDarwin.length);
});
test("a comment-only proto change rebuilds nothing", () => {
const { filtered, filteredDarwin } = runProto(
protoWith(BASE_FIELDS),
protoWith(` string Prompt = 1;\n // how many tokens to emit\n int32 Tokens = 2;\n bool UseTokenizerTemplate = 3;`)
);
assert.deepEqual(filtered, []);
assert.deepEqual(filteredDarwin, []);
});
test("unresolvable proto revisions conservatively rebuild everything", () => {
// Same posture as the previousMatrix fallback: if we cannot resolve what the
// proto used to say, we must not claim the change was additive.
const { filtered, filteredDarwin } = runProto(null, protoWith(BASE_FIELDS));
assert.equal(filtered.length, includes.length);
assert.equal(filteredDarwin.length, includesDarwin.length);
});