Hacer llamadas
Ya tengo un cliente. ¿Qué le puedo pedir?
Seis recursos. Cada uno devuelve un objeto tipado que además conserva el payload crudo, así que un campo que el SDK no modela sigue siendo accesible.
Chat completions
completion = client.chat.completions.create(
model="qwen3-0.6b",
messages=[{"role": "user", "content": "Summarise this in one line: ..."}],
max_tokens=200,
)
print(completion.content) # the first choice's text
print(completion.usage.total_tokens)maxTokens := 200
completion, err := client.Chat.Create(ctx, axonium.ChatRequest{
Model: "qwen3-0.6b",
Messages: []axonium.Message{axonium.TextMessage("user", "Summarise this in one line: ...")},
MaxTokens: &maxTokens,
})
fmt.Println(completion.Content())let completion = client
.chat(&ChatRequest {
model: "qwen3-0.6b".into(),
messages: vec![Message::text("user", "Summarise this in one line: ...")],
max_tokens: Some(200),
..Default::default()
})
.await?;
println!("{}", completion.content());let completion = try await client.chat(
ChatRequest(
model: "qwen3-0.6b",
messages: [.user("Summarise this in one line: ...")],
maxTokens: 200))
print(completion.content ?? "") // the first choice's text
print(completion.usage?.totalTokens ?? 0)const completion = await client.chat.completions.create({
model: "qwen3-0.6b",
messages: [{ role: "user", content: "Summarise this in one line: ..." }],
max_tokens: 200,
});
console.log(completion.content); // the first choice's text
console.log(completion.usage?.totalTokens);content es un accesorio, no un campo. Entra al mensaje de la primera opción, y devuelve vacío en
vez de reventar cuando una respuesta no tiene opciones — algo que pasa en una generación cortada
antes de producir ninguna.
El pensamiento de un modelo de razonamiento es un campo aparte, no parte de content. Los cinco
SDK leen reasoning_content en reasoning — en una completion y, acumulado de los deltas, en un
stream. Dos consecuencias:
contentpuede venir vacío sin que pase nada malo. Medido enqwen3-0.6b: un stream de 120 chunks con todos los deltas en razonamiento ycontentvacío de principio a fin. Una interfaz que muestre solocontentno enseña nada y parece un SDK roto.usage.completion_tokenscuenta los dos, y nada los separa: el contrato no tiene un camporeasoning_tokens, así que estos SDK tampoco. Comparar la longitud de las dos cadenas es la aproximación más cercana disponible, y son caracteres y no tokens.
Campos que el gateway no soporta
El gateway acepta un subconjunto permitido y nombra de vuelta lo que ignoró. Hasta PRM-127 sí
descartaba en silencio, que es el problema que la cabecera vino a terminar: pones frequency_penalty,
nadie se queja, y nada lo aplica.
Los cinco envían un campo que no reconocen, y eso es intención de la plataforma y no nuestra: un campo no reconocido se mantiene fuera del conjunto aceptado para que siga apareciendo en la cabecera. Descartarlo te negaría la única respuesta que no puede caducar.
| un campo no reconocido | |
|---|---|
| Python | se envía, con un UnsupportedFieldWarning que lo nombra y apunta a meta.ignored_parameters |
| Go, Rust, Swift | se envía, por un canal explícito: Extra / extra / extraFields |
| TypeScript | se envía — el tipo de petición acepta cualquier clave |
Hasta el 06/10/2026 Python los descartaba, con el razonamiento de que el gateway los tiraría en
silencio. PRM-127 acabó con eso, y chat_template_kwargs es lo que lo hizo importar: llegaba a
llama.cpp desde TypeScript y no podía desde Python.
Esa lista es el modelo que el SDK tiene de lo que el gateway acepta, y un modelo puede caducar:
cuando la plataforma empezó a honrar response_format, este SDK siguió avisando de que se
descartaría durante cinco días. El gateway reporta su propio veredicto en
X-Prometheus-Ignored-Parameters, que es la respuesta autoritativa porque es suya y no una conjetura
sobre él.
TypeScript lo expone como meta.ignoredParameters — undefined cuando la cabecera no está,
porque la cabecera solo aparece cuando hay algo que reportar, y un array vacío afirmaría que el
gateway miró y no encontró nada. Los otros cuatro leen la cabecera y la tiran; el nombre de aquí es
el que copiarán.
Pasa require_parameters: true para convertir un descarte silencioso en un 400 unknown-parameter,
cuando que te den calladamente menos de lo que pediste es peor que fallar.
Apagar el pensamiento de un modelo de razonamiento
chat_template_kwargs lleva las variables que llama.cpp entrega a la plantilla del propio modelo. Es
como se impide que un modelo de razonamiento piense antes de cada respuesta, y las mediciones de la
plataforma lo argumentan mejor que la prosa:
qwen36-35b-a3b-q4 without 215 tokens 6.91 s
qwen36-35b-a3b-q4 enable_thinking: false 16 tokens 0.71 sLas claves son de la plantilla de cada modelo, no del gateway. El mapping se reenvía sin
examinar, el conjunto útil cambia por modelo —enable_thinking en la familia Qwen3.6,
reasoning_effort en gpt-oss— y una clave que la plantilla no lee se ignora en silencio. Mira la
ficha del modelo; nada en la respuesta puede decírtelo.
reasoning_effort y reasoning_budget en la raíz no hacen nada. Medido contra un servidor vivo:
salida idéntica byte a byte con y sin ellos. Se mantienen fuera del conjunto aceptado a propósito,
para que sigan apareciendo en X-Prometheus-Ignored-Parameters en vez de aceptarse y descartarse en
silencio. reasoning_effort va dentro de chat_template_kwargs, que es donde la plantilla lo lee.
Esa es además la regla que siguen estos SDK con cualquier campo que no reconocen: se envía, no se
descarta, para que conteste la cabecera del gateway. La lista permitida de un SDK puede caducar
—esta avisó durante cinco días de que response_format se descartaría, después de que la plataforma
empezara a honrarlo— y la cabecera no.
Salida estructurada
schema = {"type": "json_schema", "json_schema": {"name": "capital", "schema": {
"type": "object", "properties": {"capital": {"type": "string"}}, "required": ["capital"]}}}
completion = client.chat.completions.create(
model="qwen3-0.6b",
messages=[{"role": "user", "content": "Capital of Peru?"}],
response_format=schema,
)
answer = json.loads(completion.content) # a JSON string, not a nested objectcompletion, err := client.Chat.Create(ctx, axonium.ChatRequest{
Model: "qwen3-0.6b",
Messages: []axonium.Message{axonium.TextMessage("user", "Capital of Peru?")},
ResponseFormat: schema,
})
var answer map[string]any
err = json.Unmarshal([]byte(completion.Content()), &answer)let completion = client
.chat(&ChatRequest {
model: "qwen3-0.6b".into(),
messages: vec![Message::text("user", "Capital of Peru?")],
response_format: Some(schema),
..Default::default()
})
.await?;
let answer: serde_json::Value = serde_json::from_str(&completion.content())?;var request = ChatRequest(model: "qwen3-0.6b", messages: [.user("Capital of Peru?")])
request.responseFormat = schema
let completion = try await client.chat(request)
let answer = try JSONSerialization.jsonObject(with: Data((completion.content ?? "").utf8))import { jsonSchema } from "axonium";
const completion = await client.chat.completions.create({
model: "qwen3-0.6b",
messages: [{ role: "user", content: "Capital of Peru?" }],
// Accepts a plain JSON Schema, or anything with a toJSONSchema() method -- a Zod schema goes in
// without this package ever importing Zod, which is what keeps it at zero dependencies.
response_format: jsonSchema("capital", { type: "object", properties: { capital: { type: "string" } } }),
});
const answer = JSON.parse(completion.content); // a JSON string, not a nested objectLa gramática es del motor, y el esquema se reenvía verbatim — validarlo aquí sería una segunda copia de las reglas del motor, separándose de la primera.
Manda una tupla de zod 4 tal cual viene. llama.cpp rechaza un items booleano — 400 JSON schema conversion failed: Unrecognized schema: false — que es exactamente cómo zod cierra una tupla, y el
gateway ya lo traduce antes de reenviar (items: false pasa a maxItems). No lo esquives
ensanchando la tupla a items: {"type": "string"}: es lo que la gente hacía, y tira los tipos por
posición que hacían que fuera una tupla.
De dieciséis características de esquema medidas contra el motor, solo items falla como booleano.
additionalProperties, propertyNames, contains, not, enum, oneOf/anyOf/allOf,
$ref/$defs, pattern, format, const, minimum/maximum, uniqueItems y
minItems/maxItems pasan todas.
Cuánta confianza tenía el modelo
logprobs devuelve la probabilidad del token elegido; top_logprobs añade las N alternativas más
probables en cada posición. El objetivo es que un agente decida cuándo escalar a una persona en
vez de actuar sobre una suposición.
completion = client.chat.completions.create(
model="qwen3-0.6b",
messages=[{"role": "user", "content": "yes or no?"}],
logprobs=True,
top_logprobs=3,
)
for token in completion.choices[0].logprobs.content:
print(token.token, token.probability) # 0.99946, not -0.00054yes := true
three := 3
completion, err := client.Chat.Create(ctx, axonium.ChatRequest{
Model: "qwen3-0.6b",
Messages: []axonium.Message{axonium.TextMessage("user", "yes or no?")},
Logprobs: &yes,
TopLogprobs: &three,
})
for _, token := range completion.Choices[0].Logprobs.Content {
fmt.Println(token.Token, token.Probability())
}let completion = client
.chat(&ChatRequest {
model: "qwen3-0.6b".into(),
messages: vec![Message::text("user", "yes or no?")],
logprobs: Some(true),
top_logprobs: Some(3),
..Default::default()
})
.await?;
for token in &completion.choices[0].logprobs.as_ref().unwrap().content {
println!("{} {}", token.token, token.probability());
}var request = ChatRequest(model: "qwen3-0.6b", messages: [.user("yes or no?")])
request.logprobs = true
request.topLogprobs = 3
let completion = try await client.chat(request)
for token in completion.choices[0].logprobs ?? [] {
print(token.token ?? "", token.probability ?? 0)
}const completion = await client.chat.completions.create({
model: "qwen3-0.6b",
messages: [{ role: "user", content: "yes or no?" }],
logprobs: true,
top_logprobs: 3,
});
for (const token of completion.logprobs ?? []) {
console.log(token.token, token.probability); // 0.99946, not -0.00054
}Son logaritmos naturales. -0,00054 es alrededor del 99,95 % y -7,6 alrededor del 0,05 %.
Leído como probabilidad parece un número cercano a cero que significa «improbable», y el error no
hace ruido — así que los cinco exponen probability en vez de obligarte a recordar que hay que
llamar a exp. Es ausente y no cero cuando el backend no mandó logprob: un token del que no
dijo nada es un hecho distinto de uno que dijo imposible.
top_logprobs necesita logprobs. Mandarlo solo, o junto a logprobs: false, es un 422 que
el gateway lanza antes de que el motor lo vea — la regla es de llama.cpp y el gateway la aplica para
que el rechazo te llegue en el sobre de siempre. Los cinco SDK lo rechazan en el sitio de la llamada,
porque un viaje de ida y vuelta para que te lo digan no aporta nada.
Que logprobs no venga en la respuesta significa que el motor no tiene la capacidad, no que el
modelo dudara. La respuesta simplemente no lleva la clave. Manda require_parameters para que te lo
digan en vez de deducirlo de una ausencia.
La respuesta llega como una cadena JSON en el contenido, no como un objeto anidado, y los SDK no
la parsean por ti. El mismo motivo por el que los arguments de una herramienta siguen siendo una
cadena: una generación cortada por max_tokens la deja truncada, y un modelo de respuesta que lanza
desde dentro es peor que uno que te entrega lo que llegó. No es hipotético — la primera respuesta
estructurada que medimos escribiendo esto volvió como {\n "capital": "Lima"\n con
finish_reason: length.
Streaming
Un método aparte, no una bandera. Eso mantiene honesto el tipo de retorno, hace explícita la
comprobación del scope inference:stream, y da un solo sitio donde decir cuál de los dos fallos de
un stream se reintenta.
with client.chat.completions.stream(
model="qwen3-0.6b",
messages=[{"role": "user", "content": "Count to five"}],
) as stream:
for chunk in stream:
print(chunk.content, end="", flush=True)
print(stream.usage)stream, err := client.Chat.Stream(ctx, req)
if err != nil {
return err
}
defer stream.Close()
for stream.Next() {
fmt.Print(stream.Current().Content())
}
if err := stream.Err(); err != nil {
return err
}let mut stream = client.chat_stream(&request).await?;
while let Some(chunk) = stream.next().await? {
print!("{}", chunk.content());
}let stream = try await client.chatStream(
ChatRequest(model: "qwen3-0.6b", messages: [.user("Count to five")]))
for try await chunk in stream {
print(chunk.content ?? "", terminator: "")
}
print(await stream.usage() as Any)const stream = await client.chat.completions.stream({
model: "qwen3-0.6b",
messages: [{ role: "user", content: "Count to five" }],
});
for await (const chunk of stream) {
process.stdout.write(chunk.content ?? "");
}
console.log(stream.usage);Cuatro cosas que el stream maneja y un lector SSE ingenuo no:
- El centinela
[DONE]termina la iteración; no se entrega como chunk. - Un rechazo que llega en vez del stream es un status HTTP real, no un frame SSE — el gateway lee
el status del motor antes de que existan las cabeceras
200/text/event-stream. Lanza el mismo error tipado que lanzaría la llamada no-streaming, y se reintenta como cualquier otra petición. Ver Fallos. - Un error en banda — un evento decodificado con una clave
errorde nivel superior — lanzaStreamInterruptedErrorcon el texto acumulado hasta ese momento, para que una respuesta parcial no se pierda con la excepción. La detección es por presencia de la clave, no por comparar el texto del mensaje, porque solo hay un mensaje documentado y no hay motivo para creer que sea el único. Este nunca se reintenta: parte de la respuesta se entregó y parte se facturó. - Un stream cortado sin
[DONE]termina la iteración con lo que llegó, en vez de colgarse.
Soltar el stream cancela la petición. En Rust eso es Drop; en Go es el context; en Python es
salir del bloque with.
Embeddings, imágenes, rerank
vectors = client.embeddings.create(model="qwen3-embedding", input=["first", "second"])
image = client.images.generate(model="sd-turbo", prompt="a lighthouse at dusk")
open("out.png", "wb").write(image.data[0].to_bytes())
ranked = client.rerank.create(
model="qwen3-reranker",
query="annual membership fee",
documents=["Rates schedule", "Opening hours", "Card benefits"],
)
print(ranked.ranking) # indices into the documents you sent, best firstvectors, err := client.Embeddings.Create(ctx, axonium.EmbeddingRequest{
Model: "qwen3-embedding",
Input: []string{"first", "second"},
})
ranked, err := client.Rerank.Create(ctx, axonium.RerankRequest{
Model: "qwen3-reranker",
Query: "annual membership fee",
Documents: []string{"Rates schedule", "Opening hours", "Card benefits"},
})
fmt.Println(ranked.Ranking())let ranked = client
.rerank(&RerankRequest {
model: "qwen3-reranker".into(),
query: "annual membership fee".into(),
documents: vec!["Rates schedule".into(), "Opening hours".into()],
..Default::default()
})
.await?;
println!("{:?}", ranked.ranking());let vectors = try await client.embeddings(
EmbeddingRequest(model: "qwen3-embedding", input: ["first", "second"]))
let ranked = try await client.rerank(
RerankRequest(
model: "qwen3-reranker",
query: "annual membership fee",
documents: ["Rates schedule", "Opening hours", "Card benefits"]))
print(ranked.ranking)const vectors = await client.embeddings.create({
model: "qwen3-embedding",
input: ["first", "second"],
});
const image = await client.images.generate({ model: "sd-turbo", prompt: "a lighthouse at dusk" });
await writeFile("out.png", image.data[0]!.toBytes());
const ranked = await client.rerank.create({
model: "qwen3-reranker",
query: "annual membership fee",
documents: ["Rates schedule", "Opening hours", "Card benefits"],
});
console.log(ranked.ranking); // indices into the documents you sent, best firstraw_scores pide el logit en vez de la probabilidad. Las probabilidades de un reranker se
saturan cerca de 1,0 — se midió 0,99 para un documento solo vagamente relacionado con su consulta — y
una probabilidad saturada no se puede calibrar mientras que el logit de detrás sí. No todos los
motores lo tienen; donde no, la petición sigue teniendo éxito y el campo vuelve nombrado en
X-Prometheus-Ignored-Parameters, así que mandarlo siempre es seguro y que se descarte es
descubrible y no silencioso.
Rerank puntúa el conjunto entero de documentos en una petición. Contra un presupuesto de 60 RPM,
puntuar 50 candidatos cuesta una unidad y no cincuenta. El index de cada resultado apunta al array
que tú mandaste, nunca a los resultados, que es lo que mantiene un resultado reordenado
atribuible a su entrada.
Predict — las tareas para las que OpenAI no tiene forma
Tres modalidades van aquí y a ningún otro sitio: classification, zero_shot y typed_decision.
Todos los demás endpoints tienen forma de OpenAI porque cada tarea que sirven tiene un endpoint de
OpenAI al que parecerse. Estas no.
result = client.predict.create("sst2-clf", {"inputs": "El servicio ha sido excelente"})
result.value # [{"label": "POSITIVE", "score": 0.9783}] -- a LIST, not a dict
result = client.predict.create(
"von-decide",
{"inputs": "Me cobraron dos veces la misma factura",
"parameters": {"candidate_labels": ["cargo duplicado", "cliente satisfecho"]}},
)
result.value["labels"][0] # "cargo duplicado"result, err := client.Predict.Create(ctx, "sst2-clf",
map[string]any{"inputs": "El servicio ha sido excelente"}, axonium.PredictOptions{})
var labels []struct {
Label string `json:"label"`
Score float64 `json:"score"`
}
err = result.Into(&labels)let result = client
.predict(
"sst2-clf",
&serde_json::json!({"inputs": "El servicio ha sido excelente"}),
&PredictOptions::default(),
)
.await?;
let labels: Vec<Label> = result.decode()?;let result = try await client.predict(
model: "sst2-clf", body: ["inputs": "El servicio ha sido excelente"])
// A top-level ARRAY from this engine, an object from the next one. Decode what you expect from
// the payload_schema the catalog gave you, not from the modality.
let labels: [[String: JSONValue]] = try result.decode()const result = await client.predict.create("sst2-clf", {
inputs: "El servicio ha sido excelente",
});
result.value; // [{ label: "POSITIVE", score: 0.9783 }] -- an ARRAY, not an object
const decided = await client.predict.create("von-decide", {
inputs: "Me cobraron dos veces la misma factura",
parameters: { candidate_labels: ["cargo duplicado", "cliente satisfecho"] },
});El cuerpo va al motor verbatim y su respuesta vuelve verbatim. Inventar un cuerpo para estas sería el gateway decidiendo, en nombre del motor, cómo debería ser la API del motor.
Así que la respuesta se entrega sin decodificar, y eso no es tipado defensivo. sst2-clf contesta
un array de nivel superior; von-decide y laya-decide contestan objetos. Un cliente que modelara
esto como un diccionario reportaría «esto no es JSON» sobre JSON válido, para el primer motor que la
plataforma sacó en esta ruta.
Despacha por payload_schema, no por modality. sst2-clf y von-decide son los dos
clasificadores y quieren cuerpos distintos, así que aquí no hay un classify(text) — un método tipado
prometería una estabilidad que el endpoint no ofrece. El catálogo dice qué contrato habla cada modelo:
| modelo | modality |
payload_schema |
|---|---|---|
sst2-clf |
classification |
hf-inference.text-classification.v1 |
von-decide |
zero_shot |
hf-inference.zero-shot-classification.v1 |
laya-decide |
typed_decision |
typed-decision.v1 |
nli-tei |
zero_shot |
tei.predict.v1 |
emotions-tei |
classification |
tei.predict.v1 |
Las dos últimas filas son por lo que esa tabla importa. von-decide y nli-tei son los dos
zero_shot y contestan cosas distintas que las dos suman 1: el primero normaliza entre las
etiquetas candidatas que tú diste, el segundo entre las clases del propio modelo y no tiene
noción de candidatas en absoluto. Despacha por modality y lees una como la otra, en silencio.
tei.predict.v1 lleva además una trampa que conviene conocer antes de lotear:
inputs: "a text" → one flat list of {label, score}
inputs: ["premise", "hypothesis"] → ONE PAIR, not a batch of two texts
inputs: ["a", "b", "c"] → 422
inputs: [["a"], ["b"]] → a batch of two single texts → two listsUn lote es siempre una lista de listas. El obvio «manda mis N textos como array» es la única forma que devuelve calladamente una sola respuesta equivocada. Es también por lo que el resultado se entrega sin decodificar: una entrada devuelve lista plana y un lote devuelve lista de listas, del mismo modelo y el mismo endpoint.
Lo que no pasa a través es la política. El modelo sigue resolviéndose, siguen haciendo falta
inference:read más el scope específico model:<id>, una réplica muerta se sigue saltando, y la
petición se sigue midiendo y sigue contando contra un tope de gasto. Las tres modalidades comparten
un presupuesto de rate-limit, llamado predict — así que las peticiones de clasificación y las
decisiones tipadas se comen los mismos 60 RPM.
Un modelo que sí tiene endpoint de OpenAI se rechaza aquí con 400 modality-mismatch, el inverso
de la comprobación de todos los demás manejadores. Sin eso el mismo modelo sería alcanzable por dos
caminos, con dos rutas de facturación, y la que facturara bien sería la que no usaste.
El catálogo
client.models.list() # what this token may call
client.models.mine() # the subset your token is scoped to, cachedclient.Models.List(ctx) // what this token may call
client.Models.Mine(ctx) // the subset your token is scoped to, cachedclient.models().await?; // what this token may call
client.models_mine().await?; // the subset your token is scoped to, cachedtry await client.models() // what this token may call
try await client.modelsMine() // the subset your token is scoped to, cachedawait client.models.list(); // what this token may call
await client.models.mine(); // the subset your token is scoped to, cachedEl catálogo lista las instancias en marcha intersecadas con tus scopes, medido el 05/10/2026. Un modelo registrado pero parado desaparece exactamente igual que uno que no existe, e igual que uno que tu token no puede llamar — tres hechos distintos detrás de una ausencia, y solo un operador los distingue. Así que una lista vacía nunca es evidencia de que el despliegue no tenga modelos.
El acceso es denegar-por-defecto y se concede por modelo, y el streaming necesita un scope distinto
del no-streaming: tener inference:read no concede inference:stream. Cuando llega un 403 y ya se
ha leído models.mine(), el SDK dice cuál de los dos te falta en vez de solo que se denegó el acceso.
Uso de una petición
row = client.usage.retrieve(completion.meta.request_id)
row.usage.total_tokens
row.cost_usd # None where no price is configured — not 0.0
row.termination_reasonrow, err := client.Usage.Retrieve(ctx, completion.Meta.RequestID)
row.Usage.TotalTokens
row.CostUSD // nil where no price is configured — not 0
row.TerminationReasonlet row = client.usage(&completion.meta.request_id).await?;
row.usage.total_tokens;
row.cost_usd; // None where no price is configured -- not 0.0
row.termination_reason;let row = try await client.usage(requestID: completion.meta.requestID)
row.usage.totalTokens
row.costUSD // nil where no price is configured -- not 0
row.terminationReasonconst row = await client.usage.get(completion.meta.requestId!);
row.usage.totalTokens;
row.costUsd; // undefined where no price is configured -- not 0
row.terminationReason;Esto no necesita admin:read. cost_usd es anulable a propósito: «nadie puso precio a esto» y «no
costó nada» son hechos distintos, y una columna que no los distingue reporta 0,00 $ para tráfico
que nunca fue gratis.
Siguiente: Fallos, reintentos e idempotencia.