A provider may serve a model under an id of its own: namespaced with a prefix, dated, a `-latest` that is not what models.dev calls it, or simply a different spelling. magpie sends the name it knows the model by, and the provider answers that it has no such model. magpie model wire relay-b/model-2 vendor-c/model-2-preview magpie model wire 'relay-b/*' 'vendor-c/*' magpie model wire relay-b/model-2 --reset A `*` in the **value** stands for the model, so `relay-b/*` set to `vendor-c/*` sends `vendor-c/model-3` for model-3. A value with no `*` sends every model of that provider under one name, which is the case where a relay really does serve them alike. The CLI shows the pattern the way it was given, with a concrete line for one of the models, so what is in force is not something you have to imagine. An exact key wins over the provider's. The canonical id stays canonical. `Entry.Model` is not rewritten: it is the key automatic routing groups are built from, what `Resolve` returns as the model's identity, what `GET /models` lists, what an agent asks for, and what a call is recorded and priced as. The alias goes into the request's model field and nowhere else — which is the part that was wrong at first. Handing it to the serializer gave the provider's name to the code that decides what a request should look like: `buildChat` checks the model for Gemini compatibility, `buildAnthropic` matches it for adaptive-only and effort handling, `buildCodeAssist` maps by model. A provider serving a model under a namespaced id got a request shaped as if it were that other model. So the request is shaped from the model magpie knows and the field is rewritten once at the end, with `rewriteModel` — which already existed to put one provider's own model name in a body and keeps every other byte as it was. It has to reach every path. A request the provider understands as it came is relayed by `passthrough` without being rebuilt, and the Anthropic token count is asked of the provider directly; both rewrite the field too, so on a provider speaking the client's own protocol — the common case for a relay fronting one vendor — the alias still applies. "Swapped" compared the canonical id with what the vendor's reply named, so an alias that is not a prefix or a dated variant marked every call swapped. It compares the name that was actually sent, in the gateway and in the ledger alike; the ledger reads the map once for the lot, since its rows are what a reader sets beside a bill. The Codex backend path is left alone: it sends magpie's own pick and never an alias. `provider.Test` asked with the canonical id, so a provider that only accepts the alias reported a failure while its real requests worked. It asks with the alias now and still reports the canonical id, since that is the one the user picked. A blank name was a hole: the setter trimmed what it was given, so a value of only whitespace was removed on the way in, while the read path would have used it had anything else written it — a hand-edited file, or a build from before the trim. Both ends treat a blank as no name now. The key is checked where it is written rather than in Save, so one hand-edited entry cannot block every save, and #288's walk moves it on a provider rename without a line here. Co-authored-by: yetone <yetoneful@gmail.com>
576 lines
17 KiB
Go
576 lines
17 KiB
Go
package gateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/yetone/magpie/internal/provider"
|
|
)
|
|
|
|
// ---- Google Code Assist (provider side) --------------------------------------
|
|
//
|
|
// Gemini CLI and Antigravity sign in with Google and talk to Code Assist
|
|
// (cloudcode-pa.googleapis.com), which takes a Gemini request wrapped in an
|
|
// envelope — {model, project, request} — and streams replies wrapped the
|
|
// same way, {response}. The gateway builds {model, request}; the account
|
|
// adds the project and the ids its app sends when it signs the request.
|
|
|
|
// skipSignature stands in for a thought signature on a function call the
|
|
// model didn't make here: Google checks the ones Gemini 3 hands out, and
|
|
// this one tells it not to.
|
|
const skipSignature = "skip_thought_signature_validator"
|
|
|
|
var unsafeToolID = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
|
|
|
|
// antigravityBaseOf is the model an id of Antigravity's own is one level
|
|
// of; a var so tests can stand in for it.
|
|
var antigravityBaseOf = provider.AntigravityBase
|
|
|
|
// accountAgent is the agent behind p's account, and "" where p is one of the
|
|
// user's own and has none: the question a rule keyed by the account has to be
|
|
// able to answer for, and one a provider's id cannot answer — a relay added
|
|
// under a built-in's id is that relay and not that account.
|
|
func accountAgent(p provider.Provider) string {
|
|
if p.Account == nil {
|
|
return ""
|
|
}
|
|
return p.Account.Agent
|
|
}
|
|
|
|
// codeAssistID is the id a request on the account's app goes out under: on
|
|
// Antigravity the variant the effort picks for a model that is a family of
|
|
// levels, and elsewhere the model magpie knows. It is keyed by the account
|
|
// (accountAgent) rather than by the provider's id, since that is the account
|
|
// the variants belong to, and it is the id a ledger judges what the vendor
|
|
// answered against (provider.AntigravitySentID), so the gateway and the
|
|
// ledger read one rule rather than one each.
|
|
// The envelope is built under it and named with it, so it is worked out
|
|
// once where both of those are and handed down.
|
|
func codeAssistID(model, effort, agent string) string {
|
|
if agent == "antigravity" {
|
|
return provider.AntigravitySentID(model, effort)
|
|
}
|
|
return model
|
|
}
|
|
|
|
// codeAssistBody is the envelope for a request on p's account, with the
|
|
// vendor's own name for the model in its model field, over the names in
|
|
// force (wires). It is named with the id the envelope carries, which on
|
|
// Antigravity is the variant the effort picked (codeAssistID): a name given
|
|
// for the whole provider then asks for each level by its own id instead of
|
|
// one id for every level, so the effort keeps choosing and the relay is
|
|
// asked for the model it serves at that level.
|
|
//
|
|
// The id is worked out once here and handed to the builder, since the name
|
|
// in force is looked up against the very id the envelope is addressed to:
|
|
// asking twice reads the vendor's catalogue twice over for one answer.
|
|
func codeAssistBody(p provider.Provider, r *Request, model string, wires map[string]string) []byte {
|
|
sent := codeAssistID(model, r.Effort, p.Account.Agent)
|
|
body := buildCodeAssistSent(r, sent, p.Account.Agent)
|
|
if wire := provider.UpstreamNameAsIn(wires, p.ID, model, sent); wire != sent {
|
|
body = rewriteModel(body, wire)
|
|
}
|
|
return body
|
|
}
|
|
|
|
// buildCodeAssist builds the Code Assist envelope for a request, for the
|
|
// app the account belongs to: "gemini" or "antigravity". On Antigravity a
|
|
// model that is a family of levels is asked for as the variant the effort
|
|
// picks (codeAssistID).
|
|
func buildCodeAssist(r *Request, model, agent string) []byte {
|
|
return buildCodeAssistSent(r, codeAssistID(model, r.Effort, agent), agent)
|
|
}
|
|
|
|
// buildCodeAssistSent is buildCodeAssist with the id the envelope is
|
|
// addressed to already worked out, for the caller that names the envelope
|
|
// with that same id and would otherwise work it out a second time. There is
|
|
// no model but that id in here: on Antigravity the two differ, and the one
|
|
// the request goes out under is the one everything below is shaped from.
|
|
func buildCodeAssistSent(r *Request, sent, agent string) []byte {
|
|
ag := agent == "antigravity"
|
|
fixed := false // the id says the level it thinks at
|
|
if ag {
|
|
_, _, fixed = antigravityBaseOf(sent)
|
|
}
|
|
claude := strings.Contains(strings.ToLower(sent), "claude")
|
|
toolID := func(id string) string {
|
|
if !ag || id == "" {
|
|
return id
|
|
}
|
|
return unsafeToolID.ReplaceAllString(id, "_")
|
|
}
|
|
names := map[string]string{}
|
|
var contents []map[string]any
|
|
for _, m := range r.Messages {
|
|
role := "user"
|
|
if m.Role == "assistant" {
|
|
role = "model"
|
|
}
|
|
var parts []map[string]any
|
|
for _, p := range m.Parts {
|
|
switch p.Kind {
|
|
case Text:
|
|
if p.Text != "" {
|
|
parts = append(parts, map[string]any{"text": p.Text})
|
|
}
|
|
case Image, File:
|
|
if p.Data != "" {
|
|
parts = append(parts, map[string]any{"inlineData": map[string]any{"mimeType": p.MediaType, "data": p.Data}})
|
|
} else if p.URL != "" {
|
|
parts = append(parts, map[string]any{"fileData": map[string]any{"mimeType": p.MediaType, "fileUri": p.URL}})
|
|
}
|
|
case ToolCall:
|
|
names[p.ID] = p.Name
|
|
call := map[string]any{"name": p.Name, "args": json.RawMessage(argsOf(p))}
|
|
if id := toolID(p.ID); id != "" {
|
|
call["id"] = id
|
|
}
|
|
parts = append(parts, map[string]any{"functionCall": call, "thoughtSignature": skipSignature})
|
|
case ToolResult:
|
|
name := names[p.CallID]
|
|
if name == "" {
|
|
name = "tool"
|
|
}
|
|
key := "output"
|
|
switch {
|
|
case ag:
|
|
key = "result"
|
|
case p.IsError:
|
|
key = "error"
|
|
}
|
|
res := map[string]any{"name": name, "response": map[string]any{key: p.Text}}
|
|
if id := toolID(p.CallID); id != "" {
|
|
res["id"] = id
|
|
}
|
|
parts = append(parts, map[string]any{"functionResponse": res})
|
|
// the images a tool returned follow its response, as Gemini
|
|
// CLI sends a file it read
|
|
for _, im := range p.Images {
|
|
if im.Data != "" {
|
|
parts = append(parts, map[string]any{"inlineData": map[string]any{"mimeType": im.MediaType, "data": im.Data}})
|
|
} else if im.URL != "" {
|
|
parts = append(parts, map[string]any{"fileData": map[string]any{"mimeType": im.MediaType, "fileUri": im.URL}})
|
|
}
|
|
}
|
|
}
|
|
// thinking isn't sent back: its signatures belong to whoever
|
|
// made them, and Google turns away ones it didn't
|
|
}
|
|
if len(parts) == 0 {
|
|
continue
|
|
}
|
|
if n := len(contents); n > 0 && contents[n-1]["role"] == role {
|
|
contents[n-1]["parts"] = append(contents[n-1]["parts"].([]map[string]any), parts...)
|
|
continue
|
|
}
|
|
contents = append(contents, map[string]any{"role": role, "parts": parts})
|
|
}
|
|
req := map[string]any{"contents": contents}
|
|
if r.System != "" {
|
|
req["systemInstruction"] = map[string]any{"role": "user", "parts": []map[string]any{{"text": r.System}}}
|
|
}
|
|
|
|
if len(r.Tools) > 0 && r.ToolChoice != "none" {
|
|
var decls []map[string]any
|
|
for _, t := range r.Tools {
|
|
d := map[string]any{"name": t.Name}
|
|
if t.Description != "" {
|
|
d["description"] = t.Description
|
|
}
|
|
schema := t.Schema
|
|
if len(schema) == 0 {
|
|
schema = json.RawMessage(`{"type":"object","properties":{}}`)
|
|
}
|
|
if ag {
|
|
d["parameters"] = plainSchema(schema)
|
|
} else {
|
|
d["parametersJsonSchema"] = schema
|
|
}
|
|
decls = append(decls, d)
|
|
}
|
|
req["tools"] = []map[string]any{{"functionDeclarations": decls}}
|
|
mode := "AUTO"
|
|
fc := map[string]any{}
|
|
switch {
|
|
case r.ToolChoice == "required":
|
|
mode = "ANY"
|
|
case strings.HasPrefix(r.ToolChoice, "name:"):
|
|
mode = "ANY"
|
|
fc["allowedFunctionNames"] = []string{strings.TrimPrefix(r.ToolChoice, "name:")}
|
|
case ag && claude:
|
|
// Antigravity's Claude wants its calls checked against the schema
|
|
mode = "VALIDATED"
|
|
}
|
|
fc["mode"] = mode
|
|
req["toolConfig"] = map[string]any{"functionCallingConfig": fc}
|
|
}
|
|
|
|
gen := map[string]any{}
|
|
if r.MaxTokens > 0 && (!ag || claude) {
|
|
gen["maxOutputTokens"] = r.MaxTokens
|
|
}
|
|
if r.Temp != nil {
|
|
gen["temperature"] = *r.Temp
|
|
}
|
|
if r.TopP != nil {
|
|
gen["topP"] = *r.TopP
|
|
}
|
|
if len(r.Stop) > 0 {
|
|
gen["stopSequences"] = r.Stop
|
|
}
|
|
if tc := thinkingConfig(r, sent, claude, fixed); tc != nil {
|
|
gen["thinkingConfig"] = tc
|
|
// Claude's answer has to have room past its thinking
|
|
if b, ok := tc["thinkingBudget"].(int); ok && claude && gen["maxOutputTokens"] == nil {
|
|
gen["maxOutputTokens"] = b + 32000
|
|
}
|
|
}
|
|
if len(gen) > 0 {
|
|
req["generationConfig"] = gen
|
|
}
|
|
b, _ := json.Marshal(map[string]any{"model": sent, "request": req})
|
|
return b
|
|
}
|
|
|
|
// thinkingConfig says how hard the model should think: a level for
|
|
// Gemini 3, a budget for the rest. A model that doesn't think gets none,
|
|
// and one whose id already says its level (fixed: Antigravity's
|
|
// gemini-3.7-flash-low) gets no level that could say otherwise — the
|
|
// effort asked for picked that id.
|
|
//
|
|
// Gemini 3's levels: Flash takes minimal, low, medium and high, so medium
|
|
// goes as medium; Pro takes low and high only, so medium goes up to high.
|
|
func thinkingConfig(r *Request, model string, claude, fixed bool) map[string]any {
|
|
m := strings.ToLower(model)
|
|
if strings.HasPrefix(m, "gpt-oss") || claude && !strings.Contains(m, "thinking") {
|
|
return nil
|
|
}
|
|
if r.Effort == "" && !r.Thinking {
|
|
// Claude asked to think still has to be told how much
|
|
if !claude {
|
|
return nil
|
|
}
|
|
}
|
|
tc := map[string]any{"includeThoughts": true}
|
|
if strings.HasPrefix(m, "gemini-3") || strings.HasPrefix(m, "gemini-pro-agent") {
|
|
if r.Effort != "" && !fixed {
|
|
level := "high"
|
|
switch {
|
|
case r.Effort == "low":
|
|
level = "low"
|
|
case r.Effort == "medium" && strings.Contains(m, "flash"):
|
|
level = "medium"
|
|
}
|
|
tc["thinkingLevel"] = level
|
|
}
|
|
return tc
|
|
}
|
|
budget := budgetOf(effortOf(r.Effort))
|
|
if claude && r.MaxTokens > 0 && budget >= r.MaxTokens {
|
|
budget = r.MaxTokens - 1
|
|
if budget < 1024 {
|
|
return nil
|
|
}
|
|
}
|
|
tc["thinkingBudget"] = budget
|
|
return tc
|
|
}
|
|
|
|
// plainSchema is a JSON schema cut down to what Antigravity's function
|
|
// declarations take: an OpenAPI-style subset with no references, unions
|
|
// or keywords outside it.
|
|
func plainSchema(raw json.RawMessage) json.RawMessage {
|
|
var root map[string]any
|
|
if json.Unmarshal(raw, &root) != nil {
|
|
return raw
|
|
}
|
|
defs := map[string]any{}
|
|
for _, k := range []string{"$defs", "definitions"} {
|
|
if d, ok := root[k].(map[string]any); ok {
|
|
for n, v := range d {
|
|
defs[n] = v
|
|
}
|
|
}
|
|
}
|
|
var walk func(v any, depth int) any
|
|
walk = func(v any, depth int) any {
|
|
switch x := v.(type) {
|
|
case []any:
|
|
out := make([]any, len(x))
|
|
for i, e := range x {
|
|
out[i] = walk(e, depth)
|
|
}
|
|
return out
|
|
case map[string]any:
|
|
if depth > 32 {
|
|
return map[string]any{"type": "object"}
|
|
}
|
|
if ref, ok := x["$ref"].(string); ok {
|
|
name := ref[strings.LastIndex(ref, "/")+1:]
|
|
if d, ok := defs[name]; ok {
|
|
return walk(d, depth+1)
|
|
}
|
|
return map[string]any{"type": "object"}
|
|
}
|
|
// allOf: one schema with all the members' fields
|
|
if all, ok := x["allOf"].([]any); ok {
|
|
merged := map[string]any{}
|
|
for k, v := range x {
|
|
if k != "allOf" {
|
|
merged[k] = v
|
|
}
|
|
}
|
|
for _, e := range all {
|
|
if m, ok := walk(e, depth+1).(map[string]any); ok {
|
|
for k, v := range m {
|
|
if k == "properties" {
|
|
props, _ := merged["properties"].(map[string]any)
|
|
if props == nil {
|
|
props = map[string]any{}
|
|
}
|
|
for pk, pv := range v.(map[string]any) {
|
|
props[pk] = pv
|
|
}
|
|
merged["properties"] = props
|
|
} else if _, has := merged[k]; !has {
|
|
merged[k] = v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return walk(merged, depth+1)
|
|
}
|
|
// anyOf / oneOf: the first member that isn't null, nullable
|
|
for _, k := range []string{"anyOf", "oneOf"} {
|
|
if alts, ok := x[k].([]any); ok {
|
|
var pick map[string]any
|
|
nullable := false
|
|
for _, a := range alts {
|
|
m, _ := a.(map[string]any)
|
|
if m["type"] == "null" {
|
|
nullable = true
|
|
} else if pick == nil && m != nil {
|
|
pick = m
|
|
}
|
|
}
|
|
out := map[string]any{}
|
|
if pick != nil {
|
|
if m, ok := walk(pick, depth+1).(map[string]any); ok {
|
|
out = m
|
|
}
|
|
}
|
|
if d, ok := x["description"].(string); ok && out["description"] == nil {
|
|
out["description"] = d
|
|
}
|
|
if nullable {
|
|
out["nullable"] = true
|
|
}
|
|
if out["type"] == nil {
|
|
out["type"] = "string"
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
out := map[string]any{}
|
|
for k, v := range x {
|
|
switch k {
|
|
case "description", "nullable", "required", "pattern", "minimum", "maximum", "minLength", "maxLength",
|
|
"minItems", "maxItems", "minProperties", "maxProperties", "propertyOrdering":
|
|
out[k] = v
|
|
case "exclusiveMinimum", "exclusiveMaximum":
|
|
// a bound it takes only as inclusive (draft 6 on: a number)
|
|
if n, ok := v.(float64); ok {
|
|
if b := "m" + strings.TrimPrefix(k, "exclusiveM"); x[b] == nil {
|
|
out[b] = n
|
|
}
|
|
}
|
|
case "type":
|
|
if ts, ok := v.([]any); ok {
|
|
for _, t := range ts {
|
|
if t == "null" {
|
|
out["nullable"] = true
|
|
} else if out["type"] == nil {
|
|
out["type"] = t
|
|
}
|
|
}
|
|
} else {
|
|
out["type"] = v
|
|
}
|
|
case "properties":
|
|
props := map[string]any{}
|
|
if m, ok := v.(map[string]any); ok {
|
|
for pk, pv := range m {
|
|
props[pk] = walk(pv, depth+1)
|
|
}
|
|
}
|
|
out[k] = props
|
|
case "items":
|
|
out[k] = walk(v, depth+1)
|
|
case "enum":
|
|
// Google takes string enums only
|
|
if es, ok := v.([]any); ok {
|
|
strs := make([]any, 0, len(es))
|
|
for _, e := range es {
|
|
if s, ok := e.(string); ok {
|
|
strs = append(strs, s)
|
|
}
|
|
}
|
|
if len(strs) == len(es) {
|
|
out[k] = strs
|
|
}
|
|
}
|
|
default:
|
|
// a keyword outside its subset (format, default, title,
|
|
// additionalProperties, const…): Antigravity refuses the
|
|
// request over any it doesn't know (#187)
|
|
}
|
|
}
|
|
if c, ok := x["const"]; ok {
|
|
if s, ok := c.(string); ok {
|
|
out["enum"] = []any{s}
|
|
}
|
|
}
|
|
if out["type"] == nil {
|
|
switch {
|
|
case out["properties"] != nil:
|
|
out["type"] = "object"
|
|
case out["items"] != nil:
|
|
out["type"] = "array"
|
|
}
|
|
}
|
|
if req, ok := out["required"].([]any); ok {
|
|
// required names only the properties there are
|
|
props, _ := out["properties"].(map[string]any)
|
|
keep := []any{}
|
|
for _, n := range req {
|
|
if s, ok := n.(string); ok && props[s] != nil {
|
|
keep = append(keep, s)
|
|
}
|
|
}
|
|
if len(keep) == 0 {
|
|
delete(out, "required")
|
|
} else {
|
|
out["required"] = keep
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
return v
|
|
}
|
|
b, err := json.Marshal(walk(root, 0))
|
|
if err != nil {
|
|
return raw
|
|
}
|
|
return b
|
|
}
|
|
|
|
// codeAssistDecoder reads Code Assist's stream: Gemini chunks, each wrapped
|
|
// in {response}.
|
|
type codeAssistDecoder struct {
|
|
started bool
|
|
tools bool
|
|
stopped bool
|
|
usage *Usage
|
|
}
|
|
|
|
func (d *codeAssistDecoder) decode(data string, emit func(Event)) error {
|
|
var ch struct {
|
|
Response *geminiChunk `json:"response"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
geminiChunk
|
|
}
|
|
if err := json.Unmarshal([]byte(data), &ch); err != nil {
|
|
return nil
|
|
}
|
|
if ch.Error != nil {
|
|
emit(Event{Kind: KError, Text: ch.Error.Message})
|
|
return nil
|
|
}
|
|
c := ch.geminiChunk
|
|
if ch.Response != nil {
|
|
c = *ch.Response
|
|
}
|
|
if !d.started {
|
|
d.started = true
|
|
emit(Event{Kind: KStart, MsgID: c.ResponseID, Model: c.ModelVersion})
|
|
}
|
|
if c.UsageMetadata != nil {
|
|
u := c.UsageMetadata
|
|
d.usage = &Usage{Input: max(u.Prompt-u.Cached, 0), CacheRead: u.Cached,
|
|
Output: u.Candidates + u.Thoughts, Reasoning: u.Thoughts}
|
|
}
|
|
for _, cand := range c.Candidates {
|
|
for _, p := range cand.Content.Parts {
|
|
switch {
|
|
case p.FunctionCall != nil:
|
|
id := p.FunctionCall.ID
|
|
if id == "" {
|
|
id = "call_" + newID()
|
|
}
|
|
args := p.FunctionCall.Args
|
|
if len(args) == 0 || string(args) == "null" {
|
|
args = json.RawMessage("{}")
|
|
}
|
|
d.tools = true
|
|
emit(Event{Kind: KToolStart, ID: id, Name: p.FunctionCall.Name})
|
|
emit(Event{Kind: KToolArgs, Text: string(args)})
|
|
case p.Thought:
|
|
if p.Text != "" {
|
|
emit(Event{Kind: KThink, Text: p.Text})
|
|
}
|
|
if p.Signature != "" {
|
|
emit(Event{Kind: KSig, Text: p.Signature})
|
|
}
|
|
case p.Text != "":
|
|
emit(Event{Kind: KText, Text: p.Text})
|
|
}
|
|
}
|
|
if cand.FinishReason != "" && !d.stopped {
|
|
d.stopped = true
|
|
stop := stopFromGemini(cand.FinishReason)
|
|
if d.tools && stop == "stop" {
|
|
stop = "tool"
|
|
}
|
|
emit(Event{Kind: KStop, Stop: stop})
|
|
}
|
|
}
|
|
if d.stopped && d.usage != nil {
|
|
emit(Event{Kind: KUsage, Usage: *d.usage})
|
|
d.usage = nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type geminiChunk struct {
|
|
Candidates []struct {
|
|
Content struct {
|
|
Parts []gPart `json:"parts"`
|
|
} `json:"content"`
|
|
FinishReason string `json:"finishReason"`
|
|
} `json:"candidates"`
|
|
UsageMetadata *struct {
|
|
Prompt int `json:"promptTokenCount"`
|
|
Candidates int `json:"candidatesTokenCount"`
|
|
Thoughts int `json:"thoughtsTokenCount"`
|
|
Cached int `json:"cachedContentTokenCount"`
|
|
} `json:"usageMetadata"`
|
|
ModelVersion string `json:"modelVersion"`
|
|
ResponseID string `json:"responseId"`
|
|
}
|
|
|
|
func stopFromGemini(s string) string {
|
|
switch s {
|
|
case "MAX_TOKENS":
|
|
return "length"
|
|
case "STOP", "FINISH_REASON_UNSPECIFIED", "OTHER":
|
|
return "stop"
|
|
case "MALFORMED_FUNCTION_CALL", "UNEXPECTED_TOOL_CALL":
|
|
return "stop"
|
|
}
|
|
// SAFETY, RECITATION, BLOCKLIST, PROHIBITED_CONTENT, SPII, ...
|
|
return "filter"
|
|
}
|