Compare commits
16 Commits
feat/F5-au
...
4cb52e3144
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cb52e3144 | |||
|
6f956dfda3
|
|||
|
6e0f15c888
|
|||
| 66eb9f558f | |||
|
f96a2e7ed3
|
|||
| b17b555a3d | |||
|
13daf95514
|
|||
| 319b01e0b2 | |||
|
6731adca51
|
|||
|
7e11a7688c
|
|||
|
5600575ba2
|
|||
|
bc7476bf1b
|
|||
|
5a8f6bc7b3
|
|||
|
452d7d9b3d
|
|||
|
21eb211d6a
|
|||
|
f4117224fc
|
@@ -66,6 +66,7 @@ jobs:
|
||||
build_cortex: ${{ steps.changes.outputs.build_cortex }}
|
||||
build_neuron: ${{ steps.changes.outputs.build_neuron }}
|
||||
build_bench: ${{ steps.changes.outputs.build_bench }}
|
||||
build_upstream: ${{ steps.changes.outputs.build_upstream }}
|
||||
check_rust: ${{ steps.changes.outputs.check_rust }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -104,6 +105,7 @@ jobs:
|
||||
BUILD_CORTEX=true
|
||||
BUILD_NEURON=true
|
||||
BUILD_BENCH=true
|
||||
BUILD_UPSTREAM=true
|
||||
CHECK_RUST=true
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME}" = "push" ]; then
|
||||
@@ -149,6 +151,7 @@ jobs:
|
||||
NEURON_RE='^crates/neuron/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-neuron-prerelease\.spec$|^data/neuron|^neuron\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
|
||||
CORTEX_RE='^crates/cortex-gateway/|^crates/cortex-cli/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/cortex-prerelease\.spec$|^data/cortex|^cortex\.example\.toml$|^models\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
|
||||
BENCH_RE='^crates/helexa-bench/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-bench-prerelease\.spec$|^data/helexa-bench|^helexa-bench\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
|
||||
UPSTREAM_RE='^crates/helexa-upstream/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-upstream-prerelease\.spec$|^data/helexa-upstream|^helexa-upstream\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
|
||||
# Any Rust change (incl. crates not packaged here, e.g.
|
||||
# helexa-acp) still needs lint+test on main.
|
||||
RUST_RE='\.rs$|^crates/|Cargo\.toml$|^Cargo\.lock$'
|
||||
@@ -156,10 +159,12 @@ jobs:
|
||||
CORTEX_BASE=$(base_for cortex)
|
||||
NEURON_BASE=$(base_for helexa-neuron-blackwell)
|
||||
BENCH_BASE=$(base_for helexa-bench)
|
||||
UPSTREAM_BASE=$(base_for helexa-upstream)
|
||||
BUILD_CORTEX=$(decide "$CORTEX_BASE" "$CORTEX_RE")
|
||||
BUILD_NEURON=$(decide "$NEURON_BASE" "$NEURON_RE")
|
||||
BUILD_BENCH=$(decide "$BENCH_BASE" "$BENCH_RE")
|
||||
if [ "$BUILD_CORTEX" = "true" ] || [ "$BUILD_NEURON" = "true" ] || [ "$BUILD_BENCH" = "true" ]; then
|
||||
BUILD_UPSTREAM=$(decide "$UPSTREAM_BASE" "$UPSTREAM_RE")
|
||||
if [ "$BUILD_CORTEX" = "true" ] || [ "$BUILD_NEURON" = "true" ] || [ "$BUILD_BENCH" = "true" ] || [ "$BUILD_UPSTREAM" = "true" ]; then
|
||||
CHECK_RUST=true
|
||||
else
|
||||
CHECK_RUST=$(decide "$CORTEX_BASE" "$RUST_RE")
|
||||
@@ -170,8 +175,9 @@ jobs:
|
||||
echo "build_cortex=${BUILD_CORTEX}" >> "$GITHUB_OUTPUT"
|
||||
echo "build_neuron=${BUILD_NEURON}" >> "$GITHUB_OUTPUT"
|
||||
echo "build_bench=${BUILD_BENCH}" >> "$GITHUB_OUTPUT"
|
||||
echo "build_upstream=${BUILD_UPSTREAM}" >> "$GITHUB_OUTPUT"
|
||||
echo "check_rust=${CHECK_RUST}" >> "$GITHUB_OUTPUT"
|
||||
echo "### change detection: build_cortex=${BUILD_CORTEX} build_neuron=${BUILD_NEURON} build_bench=${BUILD_BENCH} check_rust=${CHECK_RUST}"
|
||||
echo "### change detection: build_cortex=${BUILD_CORTEX} build_neuron=${BUILD_NEURON} build_bench=${BUILD_BENCH} build_upstream=${BUILD_UPSTREAM} check_rust=${CHECK_RUST}"
|
||||
|
||||
# fmt + clippy + test moved here from ci.yml for main pushes so the
|
||||
# two workflows stop queueing against each other (ci.yml's checks
|
||||
@@ -303,6 +309,45 @@ jobs:
|
||||
path: artifacts/helexa-bench
|
||||
retention-days: 1
|
||||
|
||||
build-upstream:
|
||||
name: Build helexa-upstream binary
|
||||
timeout-minutes: 25
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.build_upstream == 'true'
|
||||
# Pure-Rust, non-CUDA binary — same runner as cortex/bench.
|
||||
runs-on: rust
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_BUCKET: sccache
|
||||
SCCACHE_ENDPOINT: http://caveman.kosherinata.internal:9000
|
||||
SCCACHE_REGION: auto
|
||||
SCCACHE_S3_USE_SSL: "false"
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_KEY }}
|
||||
# helexa-upstream uses the sqlx runtime query API (no compile-time
|
||||
# query macros), so it builds without a database or a .sqlx cache.
|
||||
# Set OFFLINE defensively so a stray macro can never reach for a DB.
|
||||
SQLX_OFFLINE: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Build helexa-upstream (release, sccache escalation)
|
||||
run: script/ci-cargo-escalate.sh cargo build --release -p helexa-upstream
|
||||
|
||||
- name: Stage binary
|
||||
run: |
|
||||
mkdir --parents artifacts
|
||||
cp target/release/helexa-upstream artifacts/helexa-upstream
|
||||
./artifacts/helexa-upstream --version || true
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: upstream-fc43
|
||||
path: artifacts/helexa-upstream
|
||||
retention-days: 1
|
||||
|
||||
build-neuron:
|
||||
name: Build neuron-${{ matrix.flavour }}
|
||||
timeout-minutes: 35
|
||||
@@ -459,6 +504,44 @@ jobs:
|
||||
path: ~/rpmbuild/RPMS/x86_64/*.rpm
|
||||
retention-days: 7
|
||||
|
||||
package-upstream:
|
||||
name: Package helexa-upstream RPM
|
||||
timeout-minutes: 20
|
||||
needs: [prepare, build-upstream]
|
||||
runs-on: rpm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: upstream-fc43
|
||||
path: artifacts/
|
||||
|
||||
- name: Build RPM
|
||||
run: |
|
||||
set -eux
|
||||
rm -f ~/.rpmmacros
|
||||
rpmdev-setuptree
|
||||
cp artifacts/helexa-upstream ~/rpmbuild/SOURCES/
|
||||
cp data/helexa-upstream.service ~/rpmbuild/SOURCES/
|
||||
cp data/helexa-upstream-sysusers.conf ~/rpmbuild/SOURCES/
|
||||
cp data/helexa-upstream-firewalld.xml ~/rpmbuild/SOURCES/
|
||||
cp helexa-upstream.example.toml ~/rpmbuild/SOURCES/
|
||||
cp LICENSE ~/rpmbuild/SOURCES/
|
||||
rpmbuild -bb rpm/helexa-upstream-prerelease.spec \
|
||||
--define "upstream_version ${{ needs.prepare.outputs.version }}" \
|
||||
--define "upstream_prerelease ${{ needs.prepare.outputs.release }}" \
|
||||
--undefine dist \
|
||||
--define "dist .fc43"
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: rpm-upstream-fc43
|
||||
path: ~/rpmbuild/RPMS/x86_64/*.rpm
|
||||
retention-days: 7
|
||||
|
||||
package-neuron:
|
||||
name: Package helexa-neuron-${{ matrix.flavour }} RPM
|
||||
timeout-minutes: 20
|
||||
@@ -508,7 +591,7 @@ jobs:
|
||||
publish:
|
||||
name: Publish to rpm.lair.cafe (unstable)
|
||||
timeout-minutes: 25
|
||||
needs: [lint, test, package-cortex, package-neuron, package-bench]
|
||||
needs: [lint, test, package-cortex, package-neuron, package-bench, package-upstream]
|
||||
# Runs when at least one package was built and nothing failed.
|
||||
# lint/test may be skipped (docs-only refs never get here because
|
||||
# no packages build), but a real failure in any blocks the
|
||||
@@ -518,10 +601,11 @@ jobs:
|
||||
!cancelled()
|
||||
&& (needs.lint.result == 'success' || needs.lint.result == 'skipped')
|
||||
&& (needs.test.result == 'success' || needs.test.result == 'skipped')
|
||||
&& (needs.package-cortex.result == 'success' || needs.package-neuron.result == 'success' || needs.package-bench.result == 'success')
|
||||
&& (needs.package-cortex.result == 'success' || needs.package-neuron.result == 'success' || needs.package-bench.result == 'success' || needs.package-upstream.result == 'success')
|
||||
&& needs.package-cortex.result != 'failure'
|
||||
&& needs.package-neuron.result != 'failure'
|
||||
&& needs.package-bench.result != 'failure'
|
||||
&& needs.package-upstream.result != 'failure'
|
||||
}}
|
||||
runs-on: rpm
|
||||
concurrency:
|
||||
|
||||
@@ -105,3 +105,5 @@ enabled = false
|
||||
# upstream). Override via CORTEX_UPSTREAM__BEARER in prod.
|
||||
# bearer = "replace-with-operator-client-secret"
|
||||
# timeout_secs = 5
|
||||
# How often to flush served-usage counters to upstream for reconciliation (#58).
|
||||
# served_usage_report_interval_secs = 60
|
||||
|
||||
@@ -48,11 +48,18 @@ pub struct UpstreamClientConfig {
|
||||
/// Per-call timeout (seconds) to upstream.
|
||||
#[serde(default = "default_upstream_timeout")]
|
||||
pub timeout_secs: u64,
|
||||
/// How often (seconds) to flush served-usage counters to upstream for
|
||||
/// reconciliation (#58).
|
||||
#[serde(default = "default_served_usage_interval")]
|
||||
pub served_usage_report_interval_secs: u64,
|
||||
}
|
||||
|
||||
fn default_upstream_timeout() -> u64 {
|
||||
5
|
||||
}
|
||||
fn default_served_usage_interval() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
/// `[entitlements]` — the local/static [`crate::entitlements::EntitlementProvider`]
|
||||
/// source of truth (#50). Accounts, keys, and hard caps live here; the
|
||||
|
||||
@@ -116,6 +116,23 @@ pub struct Usage {
|
||||
/// prompt caching lands (#11); `None` until then.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_tokens_details: Option<PromptTokensDetails>,
|
||||
/// helexa extension (non-OpenAI): server-measured prefill/decode
|
||||
/// timing, so the bench harness can compute true prefill vs decode
|
||||
/// tok/s instead of inferring both from client-side SSE arrival
|
||||
/// (#85). Additive and optional — standard OpenAI clients ignore
|
||||
/// it; cortex forwards usage verbatim so it survives proxying.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub helexa_timing: Option<HelexaTiming>,
|
||||
}
|
||||
|
||||
/// helexa extension carried on [`Usage::helexa_timing`]. Mirrors
|
||||
/// neuron's internal `FinishTiming`. All fields are server-measured;
|
||||
/// `prefill_tokens` is the prefill-rate denominator.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HelexaTiming {
|
||||
pub prefill_ms: u64,
|
||||
pub decode_ms: u64,
|
||||
pub prefill_tokens: u64,
|
||||
}
|
||||
|
||||
/// Sub-counts of `Usage::completion_tokens`.
|
||||
|
||||
@@ -66,14 +66,48 @@ pub struct ResponsesRequest {
|
||||
pub extra: Value,
|
||||
}
|
||||
|
||||
/// `input` is either a single string or an array of typed items.
|
||||
/// `input` is either a single string or an array of items.
|
||||
/// `#[serde(untagged)]` so the wire shape `"input": "hi"` and
|
||||
/// `"input": [{...}]` both deserialize.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ResponsesInput {
|
||||
Text(String),
|
||||
Items(Vec<ResponsesInputItem>),
|
||||
Items(Vec<ResponsesInputElement>),
|
||||
}
|
||||
|
||||
/// One element of an `input` array.
|
||||
///
|
||||
/// OpenAI's Responses API accepts three shapes here, and real clients
|
||||
/// use all of them — most notably agent-zero (via litellm), which
|
||||
/// sends the bare "easy message" form. We must tolerate every shape,
|
||||
/// because `input` is an `#[serde(untagged)]` array: a single element
|
||||
/// that matches no variant fails the *entire* request with a 422
|
||||
/// (`did not match any variant of untagged enum ResponsesInput`).
|
||||
///
|
||||
/// 1. [`Self::Typed`] — an item carrying an explicit `"type"`
|
||||
/// discriminant (`message`, `function_call`, `function_call_output`,
|
||||
/// `reasoning`).
|
||||
/// 2. [`Self::EasyMessage`] — a bare `{role, content}` with **no**
|
||||
/// `type` field. This is OpenAI's `EasyInputMessage` and what
|
||||
/// litellm emits for every turn. `content` is optional so an
|
||||
/// assistant turn carrying only tool calls (`content: null`) still
|
||||
/// parses.
|
||||
/// 3. [`Self::Other`] — anything else, captured as raw JSON and
|
||||
/// dropped during translation. This is the forward-compat escape
|
||||
/// hatch that mirrors [`ResponsesRequest::extra`] at the item
|
||||
/// level: an unmodeled item type can never again reject the whole
|
||||
/// request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ResponsesInputElement {
|
||||
Typed(ResponsesInputItem),
|
||||
EasyMessage {
|
||||
role: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
content: Option<ResponsesMessageContent>,
|
||||
},
|
||||
Other(Value),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -91,8 +125,11 @@ pub enum ResponsesInputItem {
|
||||
name: String,
|
||||
arguments: String,
|
||||
},
|
||||
/// User is feeding a tool result back into the model.
|
||||
FunctionCallOutput { call_id: String, output: String },
|
||||
/// User is feeding a tool result back into the model. `output`
|
||||
/// is a `Value` because OpenAI allows it to be either a plain
|
||||
/// string or an array of content parts; the translator renders
|
||||
/// either form to text rather than losing the tool result.
|
||||
FunctionCallOutput { call_id: String, output: Value },
|
||||
/// Reasoning items emitted by o-series models. Accepted but
|
||||
/// not forwarded to the model — neuron's candle path doesn't
|
||||
/// surface reasoning separately yet.
|
||||
@@ -132,6 +169,11 @@ pub enum ResponsesContentPart {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
annotations: Vec<Value>,
|
||||
},
|
||||
/// Any content-part type we don't model (e.g. `refusal`, audio).
|
||||
/// Captured as a unit so an unknown part can't reject the whole
|
||||
/// request; dropped during translation.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
// ── Response (non-streaming) ─────────────────────────────────────────
|
||||
@@ -277,20 +319,116 @@ mod tests {
|
||||
ResponsesInput::Items(items) => {
|
||||
assert_eq!(items.len(), 1);
|
||||
match &items[0] {
|
||||
ResponsesInputItem::Message { role, content } => {
|
||||
ResponsesInputElement::Typed(ResponsesInputItem::Message { role, content }) => {
|
||||
assert_eq!(role, "user");
|
||||
match content {
|
||||
ResponsesMessageContent::Text(t) => assert_eq!(t, "hi"),
|
||||
other => panic!("expected Text content, got {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("expected Message item, got {other:?}"),
|
||||
other => panic!("expected typed Message item, got {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("expected Items, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialises_bare_easy_message_without_type() {
|
||||
// The shape agent-zero (via litellm) actually sends: `input`
|
||||
// items are bare `{role, content}` with NO `type` field. This
|
||||
// is the exact payload that was returning 422.
|
||||
let raw = r#"{
|
||||
"model": "Qwen/Qwen3.6-27B",
|
||||
"store": true,
|
||||
"tools": [{"type": "function", "name": "x", "description": "d", "parameters": {}}],
|
||||
"input": [
|
||||
{"role": "system", "content": "you are helpful"},
|
||||
{"role": "assistant", "content": "{\"tool_name\":\"response\"}"},
|
||||
{"role": "user", "content": "hi"}
|
||||
]
|
||||
}"#;
|
||||
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
|
||||
let items = match req.input {
|
||||
ResponsesInput::Items(i) => i,
|
||||
other => panic!("expected Items, got {other:?}"),
|
||||
};
|
||||
assert_eq!(items.len(), 3);
|
||||
for el in &items {
|
||||
assert!(
|
||||
matches!(el, ResponsesInputElement::EasyMessage { .. }),
|
||||
"expected EasyMessage, got {el:?}"
|
||||
);
|
||||
}
|
||||
// `tools` / `store` ride through `extra`, not `input`.
|
||||
assert!(req.extra.get("tools").is_some());
|
||||
assert_eq!(req.extra.get("store"), Some(&Value::Bool(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_null_content_and_unknown_item_types() {
|
||||
// An assistant turn carrying only tool calls has `content: null`;
|
||||
// and a future/unmodeled item type must not 422 the request.
|
||||
let raw = r#"{
|
||||
"model": "m",
|
||||
"input": [
|
||||
{"role": "assistant", "content": null},
|
||||
{"type": "item_reference", "id": "abc"},
|
||||
{"type": "function_call_output", "call_id": "c1",
|
||||
"output": [{"type": "output_text", "text": "result"}]},
|
||||
{"role": "user", "content": "go"}
|
||||
]
|
||||
}"#;
|
||||
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
|
||||
let items = match req.input {
|
||||
ResponsesInput::Items(i) => i,
|
||||
other => panic!("expected Items, got {other:?}"),
|
||||
};
|
||||
assert_eq!(items.len(), 4);
|
||||
assert!(matches!(
|
||||
&items[0],
|
||||
ResponsesInputElement::EasyMessage { content: None, .. }
|
||||
));
|
||||
assert!(matches!(&items[1], ResponsesInputElement::Other(_)));
|
||||
assert!(matches!(
|
||||
&items[2],
|
||||
ResponsesInputElement::Typed(ResponsesInputItem::FunctionCallOutput { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
&items[3],
|
||||
ResponsesInputElement::EasyMessage { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_unknown_content_part_type() {
|
||||
// A `refusal` (or any unmodeled) content part must parse, not 422.
|
||||
let raw = r#"{
|
||||
"model": "m",
|
||||
"input": [
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "refusal", "refusal": "no"},
|
||||
{"type": "output_text", "text": "ok"}
|
||||
]}
|
||||
]
|
||||
}"#;
|
||||
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
|
||||
let items = match req.input {
|
||||
ResponsesInput::Items(i) => i,
|
||||
other => panic!("expected Items, got {other:?}"),
|
||||
};
|
||||
let parts = match &items[0] {
|
||||
ResponsesInputElement::EasyMessage {
|
||||
content: Some(ResponsesMessageContent::Parts(p)),
|
||||
..
|
||||
} => p,
|
||||
other => panic!("expected EasyMessage with Parts, got {other:?}"),
|
||||
};
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert!(matches!(&parts[0], ResponsesContentPart::Unknown));
|
||||
assert!(matches!(&parts[1], ResponsesContentPart::OutputText { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialises_input_with_image() {
|
||||
let raw = r#"{
|
||||
@@ -308,10 +446,10 @@ mod tests {
|
||||
other => panic!("expected Items, got {other:?}"),
|
||||
};
|
||||
let parts = match &items[0] {
|
||||
ResponsesInputItem::Message {
|
||||
ResponsesInputElement::Typed(ResponsesInputItem::Message {
|
||||
content: ResponsesMessageContent::Parts(p),
|
||||
..
|
||||
} => p,
|
||||
}) => p,
|
||||
other => panic!("expected Parts, got {other:?}"),
|
||||
};
|
||||
assert_eq!(parts.len(), 2);
|
||||
|
||||
@@ -400,6 +400,7 @@ pub fn openai_to_anthropic(resp: ChatCompletionResponse) -> MessagesResponse {
|
||||
total_tokens: 0,
|
||||
completion_tokens_details: None,
|
||||
prompt_tokens_details: None,
|
||||
helexa_timing: None,
|
||||
});
|
||||
|
||||
MessagesResponse {
|
||||
@@ -772,6 +773,7 @@ mod stream_tests {
|
||||
total_tokens: 267,
|
||||
completion_tokens_details: None,
|
||||
prompt_tokens_details: None,
|
||||
helexa_timing: None,
|
||||
});
|
||||
t.on_chunk(&usage_chunk);
|
||||
let fin = t.finish();
|
||||
|
||||
@@ -322,7 +322,11 @@ async fn anthropic_messages(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(guard) => Some(crate::metering::usage_sink(principal, guard)),
|
||||
Ok(guard) => Some(crate::metering::usage_sink(
|
||||
principal,
|
||||
guard,
|
||||
std::sync::Arc::clone(&fleet.served_usage),
|
||||
)),
|
||||
Err(env) => return crate::error::envelope_response(env),
|
||||
}
|
||||
}
|
||||
@@ -802,7 +806,11 @@ async fn proxy_with_metrics(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(guard) => Some(crate::metering::usage_sink(principal, guard)),
|
||||
Ok(guard) => Some(crate::metering::usage_sink(
|
||||
principal,
|
||||
guard,
|
||||
std::sync::Arc::clone(&fleet.served_usage),
|
||||
)),
|
||||
Err(env) => return crate::error::envelope_response(env),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod metrics;
|
||||
pub mod poller;
|
||||
pub mod proxy;
|
||||
pub mod router;
|
||||
pub mod served_usage;
|
||||
pub mod state;
|
||||
|
||||
use anyhow::Result;
|
||||
@@ -57,6 +58,28 @@ pub async fn run(config: GatewayConfig) -> Result<()> {
|
||||
evictor::eviction_loop(evictor_fleet).await;
|
||||
});
|
||||
|
||||
// Served-usage reporter (#58): when this operator is part of the mesh,
|
||||
// periodically flush absolute per-principal served-token counters to
|
||||
// upstream for reconciliation.
|
||||
if config.upstream.enabled {
|
||||
let su_fleet = Arc::clone(&fleet);
|
||||
let url = config.upstream.url.clone();
|
||||
let bearer = config.upstream.bearer.clone();
|
||||
let interval =
|
||||
std::time::Duration::from_secs(config.upstream.served_usage_report_interval_secs);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(interval).await;
|
||||
let rows = su_fleet.served_usage.snapshot();
|
||||
if let Err(e) =
|
||||
served_usage::report(&su_fleet.http_client, &url, &bearer, &rows).await
|
||||
{
|
||||
tracing::warn!(error = %e, "served-usage report failed (will retry)");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let app = build_app(Arc::clone(&fleet));
|
||||
|
||||
let listen_addr = config.gateway.listen.parse::<std::net::SocketAddr>()?;
|
||||
|
||||
@@ -117,9 +117,21 @@ impl Drop for ReservationGuard {
|
||||
/// Build the completion sink for an authenticated request: record spend and
|
||||
/// settle the reservation with the observed total. Dropping it unused (no
|
||||
/// usage observed) releases the reservation via the guard.
|
||||
pub fn usage_sink(principal: Principal, guard: ReservationGuard) -> UsageSink {
|
||||
pub fn usage_sink(
|
||||
principal: Principal,
|
||||
guard: ReservationGuard,
|
||||
served_usage: std::sync::Arc<crate::served_usage::ServedUsage>,
|
||||
) -> UsageSink {
|
||||
Box::new(move |prompt, completion| {
|
||||
record_spend(&principal, prompt, completion);
|
||||
// Per-principal served-usage tally for #58 reconciliation. Recorded
|
||||
// for every metered (authenticated) request; the flush task reports
|
||||
// it to upstream when the operator is part of the mesh.
|
||||
served_usage.add(
|
||||
&principal.account_id,
|
||||
&principal.key_id,
|
||||
prompt + completion,
|
||||
);
|
||||
guard.settle(prompt + completion);
|
||||
})
|
||||
}
|
||||
|
||||
105
crates/cortex-gateway/src/served_usage.rs
Normal file
105
crates/cortex-gateway/src/served_usage.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
//! Served-usage ledger (#58): cortex meters, per principal and per UTC day,
|
||||
//! the tokens it has served on behalf of mesh accounts, and periodically
|
||||
//! reports **absolute** cumulative counters to helexa-upstream for
|
||||
//! reconciliation (operators are compensated for served tokens).
|
||||
//!
|
||||
//! Counters are cumulative-since-process-start for the current period;
|
||||
//! upstream upserts them monotonically (GREATEST), so re-sending the same
|
||||
//! value is idempotent and a flush that races another is harmless. (A
|
||||
//! process restart resets the in-memory counter; the monotonic upsert keeps
|
||||
//! upstream from regressing — at most it under-counts the restarted window,
|
||||
//! acceptable for beta. One cortex per operator token is assumed.)
|
||||
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct ServedRow {
|
||||
pub account_id: String,
|
||||
pub key_id: String,
|
||||
pub period: String, // YYYY-MM-DD (UTC)
|
||||
pub served_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ServedUsage {
|
||||
inner: Mutex<HashMap<(String, String, String), u64>>,
|
||||
}
|
||||
|
||||
impl ServedUsage {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Add served tokens for a principal in today's (UTC) period.
|
||||
pub fn add(&self, account_id: &str, key_id: &str, tokens: u64) {
|
||||
if tokens == 0 {
|
||||
return;
|
||||
}
|
||||
let period = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||
let mut m = self.inner.lock().expect("served-usage lock");
|
||||
*m.entry((account_id.to_string(), key_id.to_string(), period))
|
||||
.or_insert(0) += tokens;
|
||||
}
|
||||
|
||||
/// Absolute cumulative counters, for a flush to upstream.
|
||||
pub fn snapshot(&self) -> Vec<ServedRow> {
|
||||
let m = self.inner.lock().expect("served-usage lock");
|
||||
m.iter()
|
||||
.map(|((account_id, key_id, period), &served_tokens)| ServedRow {
|
||||
account_id: account_id.clone(),
|
||||
key_id: key_id.clone(),
|
||||
period: period.clone(),
|
||||
served_tokens,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// POST the absolute counters to upstream's `/authz/v1/served-usage`.
|
||||
pub async fn report(
|
||||
client: &reqwest::Client,
|
||||
base_url: &str,
|
||||
bearer: &str,
|
||||
rows: &[ServedRow],
|
||||
) -> Result<(), reqwest::Error> {
|
||||
if rows.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let url = format!("{}/authz/v1/served-usage", base_url.trim_end_matches('/'));
|
||||
client
|
||||
.post(url)
|
||||
.bearer_auth(bearer)
|
||||
.json(&serde_json::json!({ "rows": rows }))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accumulates_per_principal_and_period() {
|
||||
let su = ServedUsage::new();
|
||||
su.add("acct", "key", 10);
|
||||
su.add("acct", "key", 5);
|
||||
su.add("acct", "other", 7);
|
||||
su.add("acct", "key", 0); // no-op
|
||||
let mut rows = su.snapshot();
|
||||
rows.sort_by(|a, b| a.key_id.cmp(&b.key_id));
|
||||
assert_eq!(rows.len(), 2);
|
||||
let key_row = rows.iter().find(|r| r.key_id == "key").unwrap();
|
||||
assert_eq!(key_row.served_tokens, 15);
|
||||
assert_eq!(
|
||||
rows.iter()
|
||||
.find(|r| r.key_id == "other")
|
||||
.unwrap()
|
||||
.served_tokens,
|
||||
7
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ pub struct CortexState {
|
||||
/// Whether to reject unauthenticated requests (#49). Read by the auth
|
||||
/// middleware once it lands.
|
||||
pub require_auth: bool,
|
||||
/// Per-principal served-token tally (#58), reported to upstream for
|
||||
/// operator reconciliation by the flush task when upstream is enabled.
|
||||
pub served_usage: Arc<crate::served_usage::ServedUsage>,
|
||||
}
|
||||
|
||||
impl CortexState {
|
||||
@@ -73,6 +76,7 @@ impl CortexState {
|
||||
.expect("failed to build HTTP client"),
|
||||
entitlements,
|
||||
require_auth: config.entitlements.require_auth,
|
||||
served_usage: Arc::new(crate::served_usage::ServedUsage::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ fn chain(local: LocalEntitlementProvider, url: &str) -> ChainedEntitlementProvid
|
||||
url: url.to_string(),
|
||||
bearer: "client-secret".into(),
|
||||
timeout_secs: 5,
|
||||
served_usage_report_interval_secs: 60,
|
||||
});
|
||||
ChainedEntitlementProvider::new(local, upstream)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ use axum::http::{StatusCode, header};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use axum::{Extension, Json, Router};
|
||||
use cortex_core::error_envelope::OpenAiError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use subtle::ConstantTimeEq;
|
||||
@@ -41,6 +41,7 @@ pub fn router(state: &AppState) -> Router<AppState> {
|
||||
.route("/authz/v1/settle", post(settle))
|
||||
.route("/authz/v1/release", post(release))
|
||||
.route("/authz/v1/snapshot", post(snapshot))
|
||||
.route("/authz/v1/served-usage", post(served_usage))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
client_auth,
|
||||
@@ -274,6 +275,61 @@ async fn snapshot(State(state): State<AppState>, Json(req): Json<SnapshotReq>) -
|
||||
}
|
||||
}
|
||||
|
||||
// ── served-usage report (#58) ───────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ServedUsageReport {
|
||||
rows: Vec<ServedUsageRow>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ServedUsageRow {
|
||||
account_id: String,
|
||||
key_id: String,
|
||||
period: String, // YYYY-MM-DD
|
||||
served_tokens: i64,
|
||||
}
|
||||
|
||||
/// `POST /authz/v1/served-usage` — a cortex reports the absolute served-token
|
||||
/// counters it has accrued for the current period. Upsert is monotonic
|
||||
/// (`GREATEST`) so re-sends and races are idempotent and never regress.
|
||||
/// `operator_id` comes from the validated client bearer (request extension).
|
||||
async fn served_usage(
|
||||
State(state): State<AppState>,
|
||||
Extension(operator): Extension<OperatorId>,
|
||||
Json(req): Json<ServedUsageReport>,
|
||||
) -> Response {
|
||||
for row in &req.rows {
|
||||
let (Ok(account_id), Ok(key_id)) = (
|
||||
Uuid::parse_str(&row.account_id),
|
||||
Uuid::parse_str(&row.key_id),
|
||||
) else {
|
||||
continue; // skip malformed ids rather than fail the whole batch
|
||||
};
|
||||
let Ok(period) = chrono::NaiveDate::parse_from_str(&row.period, "%Y-%m-%d") else {
|
||||
continue;
|
||||
};
|
||||
let res = sqlx::query(
|
||||
"INSERT INTO served_usage (operator_id, account_id, key_id, period, served_tokens) \
|
||||
VALUES ($1, $2, $3, $4, $5) \
|
||||
ON CONFLICT (operator_id, account_id, key_id, period) \
|
||||
DO UPDATE SET served_tokens = GREATEST(served_usage.served_tokens, EXCLUDED.served_tokens)",
|
||||
)
|
||||
.bind(&operator.0)
|
||||
.bind(account_id)
|
||||
.bind(key_id)
|
||||
.bind(period)
|
||||
.bind(row.served_tokens.max(0))
|
||||
.execute(&state.pool)
|
||||
.await;
|
||||
if let Err(e) = res {
|
||||
tracing::error!(error = %e, "served-usage upsert failed");
|
||||
return envelope_response(OpenAiError::service_unavailable("authority error", Some(5)));
|
||||
}
|
||||
}
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
fn bad_request(msg: &str) -> Response {
|
||||
envelope_response(OpenAiError::new(
|
||||
400,
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod email;
|
||||
pub mod error;
|
||||
pub mod handlers;
|
||||
pub mod ledger;
|
||||
pub mod reconcile;
|
||||
pub mod state;
|
||||
pub mod topup;
|
||||
pub mod web;
|
||||
|
||||
@@ -36,6 +36,12 @@ enum Commands {
|
||||
#[arg(long)]
|
||||
denomination: Option<String>,
|
||||
},
|
||||
/// Roll up not-yet-reconciled served usage per operator/period (#58),
|
||||
/// stamp it reconciled, and print the totals. Payout is out of scope.
|
||||
Reconcile {
|
||||
#[arg(short, long, default_value = "helexa-upstream.toml")]
|
||||
config: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -75,6 +81,18 @@ async fn main() -> Result<()> {
|
||||
println!("{code}");
|
||||
}
|
||||
}
|
||||
Commands::Reconcile { config } => {
|
||||
let cfg = UpstreamConfig::load(&config)
|
||||
.map_err(|e| anyhow::anyhow!("failed to load config from '{config}': {e}"))?;
|
||||
let pool =
|
||||
helexa_upstream::db::connect_and_migrate(&cfg.db.url, cfg.db.max_connections)
|
||||
.await?;
|
||||
let rollup = helexa_upstream::reconcile::reconcile(&pool).await?;
|
||||
for r in &rollup {
|
||||
println!("{}\t{}\t{}", r.operator_id, r.period, r.total_served_tokens);
|
||||
}
|
||||
tracing::info!(operators_periods = rollup.len(), "reconciliation complete");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
43
crates/helexa-upstream/src/reconcile.rs
Normal file
43
crates/helexa-upstream/src/reconcile.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
//! Reconciliation rollup (#58): aggregate the served-usage ledger per
|
||||
//! operator and period for operator compensation, stamping rows
|
||||
//! `reconciled_at` so each window is settled once. The payout mechanism
|
||||
//! itself is out of scope — this produces the authoritative per-operator
|
||||
//! totals a settlement process consumes.
|
||||
|
||||
use sqlx::Row;
|
||||
use sqlx::postgres::PgPool;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RollupRow {
|
||||
pub operator_id: String,
|
||||
pub period: chrono::NaiveDate,
|
||||
pub total_served_tokens: i64,
|
||||
}
|
||||
|
||||
/// Roll up all not-yet-reconciled served-usage into per-(operator, period)
|
||||
/// totals, then stamp those rows `reconciled_at`. Returns the rollup.
|
||||
/// Idempotent: a second run finds nothing unreconciled and returns empty.
|
||||
pub async fn reconcile(pool: &PgPool) -> Result<Vec<RollupRow>, sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let rows = sqlx::query(
|
||||
// SUM(bigint) is numeric in Postgres — cast back to bigint for i64.
|
||||
"SELECT operator_id, period, SUM(served_tokens)::bigint AS total \
|
||||
FROM served_usage WHERE reconciled_at IS NULL \
|
||||
GROUP BY operator_id, period ORDER BY operator_id, period",
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
let rollup: Vec<RollupRow> = rows
|
||||
.iter()
|
||||
.map(|r| RollupRow {
|
||||
operator_id: r.get("operator_id"),
|
||||
period: r.get("period"),
|
||||
total_served_tokens: r.get::<i64, _>("total"),
|
||||
})
|
||||
.collect();
|
||||
sqlx::query("UPDATE served_usage SET reconciled_at = now() WHERE reconciled_at IS NULL")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(rollup)
|
||||
}
|
||||
116
crates/helexa-upstream/tests/served_usage_pg.rs
Normal file
116
crates/helexa-upstream/tests/served_usage_pg.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
//! Integration test for the served-usage report (#58): the idempotent,
|
||||
//! monotonic upsert and the reconcile rollup. Gated on
|
||||
//! UPSTREAM_TEST_DATABASE_URL (skips cleanly when unset).
|
||||
|
||||
use helexa_upstream::config::{ClientToken, UpstreamConfig};
|
||||
use helexa_upstream::db::connect_and_migrate;
|
||||
use helexa_upstream::email::EmailSender;
|
||||
use helexa_upstream::reconcile::reconcile;
|
||||
use helexa_upstream::state::AppState;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::Row;
|
||||
use sqlx::postgres::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
const CLIENT_TOKEN: &str = "su-test-token";
|
||||
const OPERATOR: &str = "op-su-test";
|
||||
|
||||
async fn spawn_or_skip(test: &str) -> Option<(String, PgPool)> {
|
||||
let Ok(url) = std::env::var("UPSTREAM_TEST_DATABASE_URL") else {
|
||||
eprintln!("skipping {test}: UPSTREAM_TEST_DATABASE_URL not set");
|
||||
return None;
|
||||
};
|
||||
let pool = connect_and_migrate(&url, 16).await.expect("migrate");
|
||||
let mut config = UpstreamConfig {
|
||||
server: Default::default(),
|
||||
db: helexa_upstream::config::DbSettings {
|
||||
url,
|
||||
max_connections: 16,
|
||||
},
|
||||
grant: Default::default(),
|
||||
abuse: Default::default(),
|
||||
client_auth: Default::default(),
|
||||
authz: Default::default(),
|
||||
auth: Default::default(),
|
||||
email: Default::default(),
|
||||
};
|
||||
config.client_auth.tokens.push(ClientToken {
|
||||
token: CLIENT_TOKEN.into(),
|
||||
operator_id: OPERATOR.into(),
|
||||
});
|
||||
let email = EmailSender::from_config(&config.email).unwrap();
|
||||
let state = AppState::new(pool.clone(), config, email);
|
||||
let app = helexa_upstream::build_app(state);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
Some((format!("http://{addr}"), pool))
|
||||
}
|
||||
|
||||
async fn report(base: &str, rows: Value) -> u16 {
|
||||
reqwest::Client::new()
|
||||
.post(format!("{base}/authz/v1/served-usage"))
|
||||
.bearer_auth(CLIENT_TOKEN)
|
||||
.json(&json!({ "rows": rows }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status()
|
||||
.as_u16()
|
||||
}
|
||||
|
||||
async fn stored(pool: &PgPool, account: Uuid, key: Uuid) -> i64 {
|
||||
sqlx::query(
|
||||
"SELECT served_tokens FROM served_usage WHERE operator_id = $1 AND account_id = $2 AND key_id = $3",
|
||||
)
|
||||
.bind(OPERATOR)
|
||||
.bind(account)
|
||||
.bind(key)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.get("served_tokens")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn served_usage_upsert_is_monotonic_and_reconciles() {
|
||||
let Some((base, pool)) = spawn_or_skip("served_usage_upsert_is_monotonic_and_reconciles").await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let account = Uuid::new_v4();
|
||||
let key = Uuid::new_v4();
|
||||
let period = "2026-06-23";
|
||||
let row = |n: i64| json!([{"account_id": account, "key_id": key, "period": period, "served_tokens": n}]);
|
||||
|
||||
// First report.
|
||||
assert_eq!(report(&base, row(100)).await, 204);
|
||||
assert_eq!(stored(&pool, account, key).await, 100);
|
||||
|
||||
// Re-send a higher absolute value → advances.
|
||||
assert_eq!(report(&base, row(250)).await, 204);
|
||||
assert_eq!(stored(&pool, account, key).await, 250);
|
||||
|
||||
// A lower value (e.g. a restarted cortex) must NOT regress (GREATEST).
|
||||
assert_eq!(report(&base, row(50)).await, 204);
|
||||
assert_eq!(stored(&pool, account, key).await, 250);
|
||||
|
||||
// Re-sending the same value is idempotent.
|
||||
assert_eq!(report(&base, row(250)).await, 204);
|
||||
assert_eq!(stored(&pool, account, key).await, 250);
|
||||
|
||||
// Reconcile rolls it up and stamps reconciled_at; a second run is empty.
|
||||
let rollup = reconcile(&pool).await.unwrap();
|
||||
let mine = rollup
|
||||
.iter()
|
||||
.find(|r| r.operator_id == OPERATOR)
|
||||
.expect("operator in rollup");
|
||||
assert!(mine.total_served_tokens >= 250);
|
||||
let again = reconcile(&pool).await.unwrap();
|
||||
assert!(
|
||||
again.iter().all(|r| r.operator_id != OPERATOR),
|
||||
"already reconciled"
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use cortex_core::discovery::{DiscoveryResponse, HealthResponse};
|
||||
use cortex_core::entitlements::{HEADER_ACCOUNT_ID, HEADER_KEY_ID};
|
||||
use cortex_core::harness::ModelSpec;
|
||||
use cortex_core::openai::{ChatCompletionRequest, MessageContent};
|
||||
use cortex_core::responses::{ResponsesRequest, ResponsesUsage};
|
||||
use cortex_core::responses::{OutputTokensDetails, ResponsesRequest, ResponsesUsage};
|
||||
use futures::stream::{self, StreamExt};
|
||||
use serde_json::{Value, json};
|
||||
use std::convert::Infallible;
|
||||
@@ -418,8 +418,14 @@ async fn responses(
|
||||
input_tokens: u.prompt_tokens,
|
||||
output_tokens: u.completion_tokens,
|
||||
total_tokens: u.prompt_tokens + u.completion_tokens,
|
||||
// Non-streaming reasoning accounting deferred (#64).
|
||||
output_tokens_details: None,
|
||||
// Carry the reasoning sub-count through from the chat
|
||||
// usage — the non-streaming path now splits off the
|
||||
// `<think>` span and counts it (see `split_off_reasoning`).
|
||||
output_tokens_details: u.completion_tokens_details.as_ref().map(|d| {
|
||||
OutputTokensDetails {
|
||||
reasoning_tokens: d.reasoning_tokens,
|
||||
}
|
||||
}),
|
||||
input_tokens_details: None,
|
||||
});
|
||||
let meta = openai_responses::ResponseMeta {
|
||||
|
||||
@@ -23,11 +23,11 @@ use candle_transformers::models::qwen3_moe as qwen3_moe_dense;
|
||||
use cortex_core::harness::{Harness, HarnessHealth, ModelInfo, ModelSpec};
|
||||
use cortex_core::openai::{
|
||||
ChatCompletionChoice, ChatCompletionChunk, ChatCompletionRequest, ChatCompletionResponse,
|
||||
ChatMessage, MessageContent, Usage,
|
||||
ChatMessage, CompletionTokensDetails, MessageContent, Usage,
|
||||
};
|
||||
|
||||
use crate::wire::{
|
||||
FinishReason, InferenceEvent, ReasoningTokenPair, ToolCallTokenPair,
|
||||
FinishReason, FinishTiming, InferenceEvent, ReasoningTokenPair, ToolCallTokenPair,
|
||||
detect_reasoning_token_pair, detect_tool_call_token_pair, openai_chat as wire_chat,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
@@ -784,6 +784,39 @@ impl ModelArch {
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a non-streaming completion's generated tokens into the
|
||||
/// visible answer and the leading reasoning span.
|
||||
///
|
||||
/// Reasoning models (Qwen3 `<think>`, DeepSeek-R1, …) emit their
|
||||
/// chain-of-thought *before* the answer, and the chat template injects
|
||||
/// the **opening** marker into the prompt — so the generated tokens look
|
||||
/// like `…reasoning… </think> …answer…` with no opening marker present
|
||||
/// in the output. The streaming path drops reasoning as
|
||||
/// [`InferenceEvent::ReasoningDelta`]; the non-streaming path has to do
|
||||
/// the equivalent post-hoc or the chain-of-thought leaks into the
|
||||
/// assistant `content` (which broke agent-zero v2.0, whose parser
|
||||
/// expected the bare JSON answer, not a `<think>` preamble).
|
||||
///
|
||||
/// Returns `(content_ids, reasoning_token_count)`. Strategy: if the model
|
||||
/// declares a reasoning marker pair and its **close** token appears in
|
||||
/// `generated_ids`, everything up to and including the last close token is
|
||||
/// reasoning and only the tail is the answer. Otherwise (non-reasoning
|
||||
/// model, thinking disabled, or a generation truncated mid-reasoning) the
|
||||
/// tokens are returned unchanged. Splitting on the token id — not a
|
||||
/// decoded `</think>` string — keeps this robust against tokenizer
|
||||
/// byte-fallback and special-token handling.
|
||||
fn split_off_reasoning<'a>(
|
||||
generated_ids: &'a [u32],
|
||||
reasoning: Option<&ReasoningTokenPair>,
|
||||
) -> (&'a [u32], u64) {
|
||||
if let Some(pair) = reasoning
|
||||
&& let Some(idx) = generated_ids.iter().rposition(|&t| t == pair.close_id)
|
||||
{
|
||||
return (&generated_ids[idx + 1..], (idx + 1) as u64);
|
||||
}
|
||||
(generated_ids, 0)
|
||||
}
|
||||
|
||||
/// Squeeze any leading singleton dims off the logits tensor so the
|
||||
/// caller gets a rank-1 `[vocab_size]` slice ready for sampling. Bails
|
||||
/// on a non-singleton leading dim (would mean a batched forward, which
|
||||
@@ -2454,21 +2487,36 @@ impl CandleHarness {
|
||||
)));
|
||||
};
|
||||
|
||||
// Strip the leading `<think>` span so the chain-of-thought
|
||||
// doesn't leak into `content` (the streaming path drops it
|
||||
// as ReasoningDelta; this is the non-streaming equivalent).
|
||||
let (content_ids, reasoning_tokens) =
|
||||
split_off_reasoning(&generated_ids, loaded.reasoning_tokens.as_ref());
|
||||
let completion_text = loaded
|
||||
.tokenizer
|
||||
.decode(&generated_ids, true)
|
||||
.decode(content_ids, true)
|
||||
.map_err(|e| InferenceError::Other(anyhow::anyhow!("detokenize: {e}")))?;
|
||||
// The first answer token after `</think>` is usually a
|
||||
// newline pair; trim it so `content` starts at the answer.
|
||||
let completion_text = if reasoning_tokens > 0 {
|
||||
completion_text.trim_start().to_string()
|
||||
} else {
|
||||
completion_text
|
||||
};
|
||||
|
||||
let usage = Usage {
|
||||
prompt_tokens: prompt_len as u64,
|
||||
completion_tokens: generated_ids.len() as u64,
|
||||
total_tokens: (prompt_len + generated_ids.len()) as u64,
|
||||
// Reasoning accounting is streaming-only: the
|
||||
// non-streaming path doesn't track `in_reasoning`
|
||||
// (would require post-hoc <think> span parsing).
|
||||
// Deferred — see #64.
|
||||
completion_tokens_details: None,
|
||||
// `reasoning_tokens` is an additive sub-count of
|
||||
// `completion_tokens` (which still counts every
|
||||
// generated token, reasoning included).
|
||||
completion_tokens_details: (reasoning_tokens > 0)
|
||||
.then_some(CompletionTokensDetails { reasoning_tokens }),
|
||||
prompt_tokens_details: None,
|
||||
// Non-streaming path: prefill/decode split is only
|
||||
// surfaced on the streaming Finish event today (#85).
|
||||
helexa_timing: None,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
@@ -3957,6 +4005,11 @@ impl CandleHarness {
|
||||
// call — promotes the terminal finish_reason to ToolCalls
|
||||
// so Anthropic clients see stop_reason: tool_use.
|
||||
let mut emitted_tool_call = false;
|
||||
// Prefill/decode split timers (#85). Declared outside 'work
|
||||
// so the terminal Finish — built after the block exits — can
|
||||
// read them; populated at the prefill→decode boundary inside.
|
||||
let mut prefill_ms_measured: u32 = 0;
|
||||
let mut decode_start: Option<std::time::Instant> = None;
|
||||
|
||||
'work: {
|
||||
// Prefix-cache decision (#11): vision requests
|
||||
@@ -4084,14 +4137,16 @@ impl CandleHarness {
|
||||
break 'work;
|
||||
}
|
||||
};
|
||||
let prefill_elapsed = prefill_start.elapsed();
|
||||
prefill_ms_measured = prefill_elapsed.as_millis() as u32;
|
||||
tp_for_task
|
||||
.prefill_rate
|
||||
.record(prompt_len, prefill_start.elapsed());
|
||||
.record(prompt_len, prefill_elapsed);
|
||||
let (post_prefill_vram_free_mb, _) = tp_for_task.query_vram().await;
|
||||
tracing::info!(
|
||||
model = %model_id,
|
||||
prompt_len,
|
||||
prefill_ms = prefill_start.elapsed().as_millis(),
|
||||
prefill_ms = prefill_elapsed.as_millis(),
|
||||
vram_free_mb = post_prefill_vram_free_mb,
|
||||
"TP chat_completion (stream): prefill complete"
|
||||
);
|
||||
@@ -4116,6 +4171,8 @@ impl CandleHarness {
|
||||
break 'work;
|
||||
}
|
||||
};
|
||||
// Decode-phase timer for the Finish prefill/decode split (#85).
|
||||
decode_start = Some(std::time::Instant::now());
|
||||
|
||||
if Some(next_token) == eos_id {
|
||||
finish_reason = FinishReason::Stop;
|
||||
@@ -4393,6 +4450,13 @@ impl CandleHarness {
|
||||
prompt_tokens: prompt_len as u32,
|
||||
completion_tokens: all_tokens.len() as u32,
|
||||
reasoning_tokens: reasoning_token_count,
|
||||
timing: Some(FinishTiming {
|
||||
prefill_ms: prefill_ms_measured,
|
||||
decode_ms: decode_start
|
||||
.map(|d| d.elapsed().as_millis() as u32)
|
||||
.unwrap_or(0),
|
||||
prefill_tokens: prompt_len as u32,
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -4722,19 +4786,31 @@ async fn chat_completion_tp_inner(
|
||||
}
|
||||
drop(pool);
|
||||
|
||||
// Strip the leading `<think>` span (see `split_off_reasoning` and the
|
||||
// single-GPU path) so the chain-of-thought doesn't leak into `content`.
|
||||
let (content_ids, reasoning_tokens) =
|
||||
split_off_reasoning(&generated, tp.reasoning_tokens.as_ref());
|
||||
let completion_text = tp
|
||||
.tokenizer
|
||||
.decode(&generated, true)
|
||||
.decode(content_ids, true)
|
||||
.map_err(|e| InferenceError::Other(anyhow::anyhow!("detokenize: {e}")))?;
|
||||
let completion_text = if reasoning_tokens > 0 {
|
||||
completion_text.trim_start().to_string()
|
||||
} else {
|
||||
completion_text
|
||||
};
|
||||
|
||||
let usage = Usage {
|
||||
prompt_tokens: prompt_len as u64,
|
||||
completion_tokens: generated.len() as u64,
|
||||
total_tokens: (prompt_len + generated.len()) as u64,
|
||||
// Reasoning accounting is streaming-only (non-streaming TP path
|
||||
// doesn't track `in_reasoning`). Deferred — see #64.
|
||||
completion_tokens_details: None,
|
||||
// `reasoning_tokens` is an additive sub-count of `completion_tokens`.
|
||||
completion_tokens_details: (reasoning_tokens > 0)
|
||||
.then_some(CompletionTokensDetails { reasoning_tokens }),
|
||||
prompt_tokens_details: None,
|
||||
// Non-streaming path: prefill/decode split is only surfaced on
|
||||
// the streaming Finish event today (#85).
|
||||
helexa_timing: None,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
@@ -6064,7 +6140,8 @@ async fn stream_inference_via_worker(
|
||||
}
|
||||
}
|
||||
};
|
||||
prefill_rate.record(prefill_prompt_len, prefill_start.elapsed());
|
||||
let prefill_elapsed = prefill_start.elapsed();
|
||||
prefill_rate.record(prefill_prompt_len, prefill_elapsed);
|
||||
let logits = Tensor::new(logits_vec.as_slice(), &Device::Cpu)?;
|
||||
let mut next_token = match sample_with_penalty(&logits, &all_tokens, &mut logits_processor) {
|
||||
Ok(t) => t,
|
||||
@@ -6077,6 +6154,8 @@ async fn stream_inference_via_worker(
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
// Decode-phase timer for the Finish prefill/decode split (#85).
|
||||
let decode_start = std::time::Instant::now();
|
||||
|
||||
// Per-token routing. `tokenizers::DecodeStream` carries five
|
||||
// generic parameters (`M, N, PT, PP, D`) which makes naming
|
||||
@@ -6221,6 +6300,11 @@ async fn stream_inference_via_worker(
|
||||
prompt_tokens: prompt_tokens.len() as u32,
|
||||
completion_tokens: all_tokens.len() as u32,
|
||||
reasoning_tokens: reasoning_token_count,
|
||||
timing: Some(FinishTiming {
|
||||
prefill_ms: prefill_elapsed.as_millis() as u32,
|
||||
decode_ms: decode_start.elapsed().as_millis() as u32,
|
||||
prefill_tokens: prefill_prompt_len as u32,
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -6355,6 +6439,10 @@ fn run_inference_streaming(
|
||||
// See `inference_tp_stream`: promotes finish_reason to ToolCalls.
|
||||
let mut emitted_tool_call = false;
|
||||
|
||||
// Time prefill and decode separately so the Finish event can carry
|
||||
// a server-measured prefill/decode split (#85) instead of leaving
|
||||
// the client to infer both from SSE chunk arrival.
|
||||
let prefill_start = std::time::Instant::now();
|
||||
let reused = restore_or_clear_local(arch, prefix_cache, prompt_tokens)?;
|
||||
// Two-stage prefill around the retokenization-stable snapshot
|
||||
// boundary — see `run_inference_via_worker`.
|
||||
@@ -6373,6 +6461,8 @@ fn run_inference_streaming(
|
||||
None => chunked_prefill_local(arch, device, prompt_tokens, reused)?,
|
||||
};
|
||||
let mut next_token = sample_with_penalty(&logits, &all_tokens, &mut logits_processor)?;
|
||||
let prefill_elapsed = prefill_start.elapsed();
|
||||
let decode_start = std::time::Instant::now();
|
||||
|
||||
// Per-token routing block, used at both the prefill-sample
|
||||
// tail and the decode loop. Macros are ugly but Rust's
|
||||
@@ -6481,6 +6571,11 @@ fn run_inference_streaming(
|
||||
prompt_tokens: prompt_tokens.len() as u32,
|
||||
completion_tokens: all_tokens.len() as u32,
|
||||
reasoning_tokens: reasoning_token_count,
|
||||
timing: Some(FinishTiming {
|
||||
prefill_ms: prefill_elapsed.as_millis() as u32,
|
||||
decode_ms: decode_start.elapsed().as_millis() as u32,
|
||||
prefill_tokens: prompt_tokens.len() as u32,
|
||||
}),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -6505,6 +6600,60 @@ mod tests {
|
||||
|
||||
const IM_START: u32 = 999;
|
||||
|
||||
fn think_pair() -> ReasoningTokenPair {
|
||||
ReasoningTokenPair {
|
||||
open_id: 100,
|
||||
close_id: 200,
|
||||
open_text: "<think>".into(),
|
||||
close_text: "</think>".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_off_reasoning_strips_up_to_close_marker() {
|
||||
// [reasoning_a, reasoning_b, </think>, answer_x, answer_y]
|
||||
let ids = [10, 11, 200, 42, 43];
|
||||
let (content, reasoning) = split_off_reasoning(&ids, Some(&think_pair()));
|
||||
assert_eq!(content, &[42, 43]);
|
||||
assert_eq!(reasoning, 3); // two reasoning tokens + the close marker
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_off_reasoning_no_close_marker_returns_all() {
|
||||
// Thinking disabled / model never closed the span: return as-is.
|
||||
let ids = [42, 43, 44];
|
||||
let (content, reasoning) = split_off_reasoning(&ids, Some(&think_pair()));
|
||||
assert_eq!(content, &ids);
|
||||
assert_eq!(reasoning, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_off_reasoning_no_marker_pair_is_noop() {
|
||||
let ids = [1, 2, 3];
|
||||
let (content, reasoning) = split_off_reasoning(&ids, None);
|
||||
assert_eq!(content, &ids);
|
||||
assert_eq!(reasoning, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_off_reasoning_close_at_end_yields_empty_content() {
|
||||
// All reasoning, answer truncated to nothing after the marker.
|
||||
let ids = [10, 11, 200];
|
||||
let (content, reasoning) = split_off_reasoning(&ids, Some(&think_pair()));
|
||||
assert!(content.is_empty());
|
||||
assert_eq!(reasoning, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_off_reasoning_splits_on_last_close_marker() {
|
||||
// Defensive: if the model emits its own <think></think> pair plus
|
||||
// the prompt-injected one, split on the LAST close marker.
|
||||
let ids = [200, 10, 200, 42];
|
||||
let (content, reasoning) = split_off_reasoning(&ids, Some(&think_pair()));
|
||||
assert_eq!(content, &[42]);
|
||||
assert_eq!(reasoning, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_snapshot_cut_lands_after_last_im_start() {
|
||||
// ChatML shape: [im_start, "system", ..., im_start, "user",
|
||||
|
||||
@@ -84,9 +84,38 @@ pub enum InferenceEvent {
|
||||
/// `output_tokens_details.reasoning_tokens` (responses).
|
||||
/// Zero for non-reasoning models.
|
||||
reasoning_tokens: u32,
|
||||
/// Server-measured prefill/decode timing for the request, or
|
||||
/// `None` on paths that don't measure it (CPU fallback that
|
||||
/// doesn't instrument, tests). Streaming projectors surface
|
||||
/// this as a `helexa_timing` extension on the OpenAI `usage`
|
||||
/// object so the bench harness can compute true prefill vs
|
||||
/// decode tok/s instead of inferring both from client-side
|
||||
/// SSE arrival (#85).
|
||||
timing: Option<FinishTiming>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Server-measured timing for one completed inference, attached to
|
||||
/// [`InferenceEvent::Finish`]. The whole point is to separate the two
|
||||
/// phases the client cannot tell apart from chunk-arrival timing:
|
||||
/// prefill (tokenize + prompt forward pass, ending at the first
|
||||
/// sampled token) and decode (every subsequent token through EOS /
|
||||
/// `max_tokens`).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FinishTiming {
|
||||
/// Wall-clock of the prefill phase in milliseconds: from the start
|
||||
/// of the prompt forward pass(es) to the first sampled token.
|
||||
pub prefill_ms: u32,
|
||||
/// Wall-clock of the decode phase in milliseconds: from the first
|
||||
/// sampled token to stream end.
|
||||
pub decode_ms: u32,
|
||||
/// Prompt tokens submitted to the prefill forward pass — the
|
||||
/// denominator for prefill tok/s. With prefix-KV-cache hits (#11)
|
||||
/// the elapsed `prefill_ms` drops while this stays the full prompt
|
||||
/// length, so a high implied rate is itself the cache-hit signal.
|
||||
pub prefill_tokens: u32,
|
||||
}
|
||||
|
||||
/// Why a stream stopped. Stays small on purpose — anything that
|
||||
/// doesn't map cleanly to one of these collapses to [`Stop`].
|
||||
///
|
||||
|
||||
@@ -22,6 +22,6 @@ pub mod openai_chat;
|
||||
pub mod openai_responses;
|
||||
|
||||
pub use event::{
|
||||
FinishReason, InferenceEvent, ReasoningTokenPair, ToolCallTokenPair,
|
||||
FinishReason, FinishTiming, InferenceEvent, ReasoningTokenPair, ToolCallTokenPair,
|
||||
detect_reasoning_token_pair, detect_tool_call_token_pair,
|
||||
};
|
||||
|
||||
@@ -26,11 +26,13 @@
|
||||
//! producer blocks on its own send. The bounded channels
|
||||
//! propagate without us writing any logic.
|
||||
|
||||
use cortex_core::openai::{ChatCompletionChunk, ChunkChoice, CompletionTokensDetails, Usage};
|
||||
use cortex_core::openai::{
|
||||
ChatCompletionChunk, ChunkChoice, CompletionTokensDetails, HelexaTiming, Usage,
|
||||
};
|
||||
use serde_json::json;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::event::{FinishReason, InferenceEvent, ReasoningTokenPair};
|
||||
use super::event::{FinishReason, FinishTiming, InferenceEvent, ReasoningTokenPair};
|
||||
|
||||
/// Output channel buffer size. Mirrors the input side's bound; one
|
||||
/// event maps to at most one chunk, so equal capacity keeps the
|
||||
@@ -193,12 +195,14 @@ pub fn project_chat_stream_with(
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
reasoning_tokens,
|
||||
timing,
|
||||
} => {
|
||||
// The finish_reason chunk, then an OpenAI-style
|
||||
// usage-only chunk (`choices: []`, `usage` populated).
|
||||
// Clients (opencode) read this to track context size;
|
||||
// cortex's Anthropic translator also picks `usage` up
|
||||
// for its `message_delta`.
|
||||
// for its `message_delta`. `timing` rides along as the
|
||||
// `helexa_timing` usage extension for the bench harness (#85).
|
||||
vec![
|
||||
final_chunk(&id, created, &model_id, reason),
|
||||
usage_chunk(
|
||||
@@ -208,6 +212,7 @@ pub fn project_chat_stream_with(
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
reasoning_tokens,
|
||||
timing,
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -334,6 +339,7 @@ fn usage_chunk(
|
||||
prompt_tokens: u32,
|
||||
completion_tokens: u32,
|
||||
reasoning_tokens: u32,
|
||||
timing: Option<FinishTiming>,
|
||||
) -> ChatCompletionChunk {
|
||||
ChatCompletionChunk {
|
||||
id: id.into(),
|
||||
@@ -351,6 +357,14 @@ fn usage_chunk(
|
||||
reasoning_tokens: reasoning_tokens as u64,
|
||||
}),
|
||||
prompt_tokens_details: None,
|
||||
// helexa extension (#85): server-measured prefill/decode
|
||||
// timing for the bench harness. Omitted on paths that don't
|
||||
// measure it so standard clients see unchanged JSON.
|
||||
helexa_timing: timing.map(|t| HelexaTiming {
|
||||
prefill_ms: t.prefill_ms as u64,
|
||||
decode_ms: t.decode_ms as u64,
|
||||
prefill_tokens: t.prefill_tokens as u64,
|
||||
}),
|
||||
}),
|
||||
extra: serde_json::Value::Object(Default::default()),
|
||||
}
|
||||
@@ -391,6 +405,7 @@ mod tests {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -413,6 +428,45 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finish_timing_surfaces_on_usage_chunk() {
|
||||
// O1 (#85) wire contract: a Finish carrying FinishTiming must
|
||||
// surface as `usage.helexa_timing` on the trailing usage chunk,
|
||||
// which is what the bench harness reads to compute true prefill
|
||||
// vs decode tok/s. Absent timing must leave it None.
|
||||
let (tx, rx) = mpsc::channel::<InferenceEvent>(4);
|
||||
let out_rx = project_chat_stream(rx, "id-1".into(), 1700, "m".into());
|
||||
|
||||
tx.send(InferenceEvent::Start).await.unwrap();
|
||||
tx.send(InferenceEvent::Finish {
|
||||
reason: FinishReason::Stop,
|
||||
prompt_tokens: 128,
|
||||
completion_tokens: 64,
|
||||
reasoning_tokens: 0,
|
||||
timing: Some(FinishTiming {
|
||||
prefill_ms: 200,
|
||||
decode_ms: 1500,
|
||||
prefill_tokens: 128,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
|
||||
let out = collect(out_rx).await;
|
||||
let usage = out
|
||||
.iter()
|
||||
.find_map(|c| c.usage.as_ref())
|
||||
.expect("usage chunk present");
|
||||
let timing = usage
|
||||
.helexa_timing
|
||||
.as_ref()
|
||||
.expect("helexa_timing populated when Finish carried timing");
|
||||
assert_eq!(timing.prefill_ms, 200);
|
||||
assert_eq!(timing.decode_ms, 1500);
|
||||
assert_eq!(timing.prefill_tokens, 128);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_text_delta_is_dropped() {
|
||||
let (tx, rx) = mpsc::channel::<InferenceEvent>(4);
|
||||
@@ -434,6 +488,7 @@ mod tests {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -496,6 +551,7 @@ mod tests {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -547,6 +603,7 @@ mod tests {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -592,6 +649,7 @@ mod tests {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -635,6 +693,7 @@ mod tests {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -662,6 +721,7 @@ mod tests {
|
||||
prompt_tokens: 42,
|
||||
completion_tokens: 5,
|
||||
reasoning_tokens: 2,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -695,6 +755,7 @@ mod tests {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 7,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -29,9 +29,9 @@
|
||||
|
||||
use cortex_core::openai::{ChatCompletionRequest, ChatMessage, MessageContent};
|
||||
use cortex_core::responses::{
|
||||
OutputTokensDetails, ResponsesContentPart, ResponsesInput, ResponsesInputItem,
|
||||
ResponsesMessageContent, ResponsesOutputContent, ResponsesOutputItem, ResponsesRequest,
|
||||
ResponsesResponse, ResponsesUsage, events,
|
||||
OutputTokensDetails, ResponsesContentPart, ResponsesInput, ResponsesInputElement,
|
||||
ResponsesInputItem, ResponsesMessageContent, ResponsesOutputContent, ResponsesOutputItem,
|
||||
ResponsesRequest, ResponsesResponse, ResponsesUsage, events,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::mpsc;
|
||||
@@ -109,8 +109,26 @@ pub fn request_to_chat(req: ResponsesRequest) -> Result<ChatCompletionRequest, T
|
||||
});
|
||||
}
|
||||
ResponsesInput::Items(items) => {
|
||||
for item in items {
|
||||
if let Some(msg) = input_item_to_chat(item) {
|
||||
for element in items {
|
||||
let msg = match element {
|
||||
ResponsesInputElement::Typed(item) => input_item_to_chat(item),
|
||||
// Bare `{role, content}` (OpenAI EasyInputMessage —
|
||||
// what litellm/agent-zero emit). `content: null`
|
||||
// (e.g. an assistant turn carrying only tool calls)
|
||||
// collapses to an empty string so the turn is kept.
|
||||
ResponsesInputElement::EasyMessage { role, content } => Some(ChatMessage {
|
||||
role,
|
||||
content: content
|
||||
.map(message_content_to_chat)
|
||||
.unwrap_or_else(|| MessageContent::Text(String::new())),
|
||||
extra: Value::Object(Default::default()),
|
||||
}),
|
||||
// Forward-compat: an item shape we don't model.
|
||||
// Dropped rather than rejected (see
|
||||
// `ResponsesInputElement::Other`).
|
||||
ResponsesInputElement::Other(_) => None,
|
||||
};
|
||||
if let Some(msg) = msg {
|
||||
messages.push(msg);
|
||||
}
|
||||
}
|
||||
@@ -159,11 +177,18 @@ fn input_item_to_chat(item: ResponsesInputItem) -> Option<ChatMessage> {
|
||||
})
|
||||
}
|
||||
ResponsesInputItem::FunctionCallOutput { call_id, output } => {
|
||||
// `output` is either a plain string or an array of content
|
||||
// parts. Render a string as-is; anything else to compact
|
||||
// JSON so the tool result text reaches the model intact.
|
||||
let output_text = match output {
|
||||
Value::String(s) => s,
|
||||
other => other.to_string(),
|
||||
};
|
||||
let mut extra = serde_json::Map::new();
|
||||
extra.insert("tool_call_id".into(), Value::String(call_id));
|
||||
Some(ChatMessage {
|
||||
role: "tool".into(),
|
||||
content: MessageContent::Text(output),
|
||||
content: MessageContent::Text(output_text),
|
||||
extra: Value::Object(extra),
|
||||
})
|
||||
}
|
||||
@@ -192,7 +217,9 @@ fn message_content_to_chat(content: ResponsesMessageContent) -> MessageContent {
|
||||
.filter_map(|p| match p {
|
||||
ResponsesContentPart::InputText { text }
|
||||
| ResponsesContentPart::OutputText { text, .. } => Some(text),
|
||||
ResponsesContentPart::InputImage { .. } => None,
|
||||
ResponsesContentPart::InputImage { .. } | ResponsesContentPart::Unknown => {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
@@ -211,6 +238,7 @@ fn message_content_to_chat(content: ResponsesMessageContent) -> MessageContent {
|
||||
"image_url": { "url": image_url },
|
||||
}));
|
||||
}
|
||||
ResponsesContentPart::Unknown => {}
|
||||
}
|
||||
}
|
||||
MessageContent::Parts(out)
|
||||
@@ -309,6 +337,9 @@ async fn run_projection(
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
reasoning_tokens,
|
||||
// Responses-side `helexa_timing` surfacing not wired yet;
|
||||
// the bench harness reads timing off the chat path (#85).
|
||||
timing: _,
|
||||
} => {
|
||||
finish = Some(reason);
|
||||
// Surface usage on the streaming `response.completed`
|
||||
@@ -535,6 +566,18 @@ mod tests {
|
||||
use super::*;
|
||||
use cortex_core::openai::MessageContent;
|
||||
|
||||
/// Wrap typed items as `input` elements. Most translator tests
|
||||
/// exercise the typed path; the bare easy-message and unknown-item
|
||||
/// paths have dedicated tests below.
|
||||
fn typed_items(items: Vec<ResponsesInputItem>) -> ResponsesInput {
|
||||
ResponsesInput::Items(
|
||||
items
|
||||
.into_iter()
|
||||
.map(ResponsesInputElement::Typed)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn meta() -> ResponseMeta {
|
||||
ResponseMeta {
|
||||
response_id: "resp_1".into(),
|
||||
@@ -614,7 +657,7 @@ mod tests {
|
||||
fn translates_input_items_to_chat_messages() {
|
||||
let req = ResponsesRequest {
|
||||
model: "m".into(),
|
||||
input: ResponsesInput::Items(vec![
|
||||
input: typed_items(vec![
|
||||
ResponsesInputItem::Message {
|
||||
role: "user".into(),
|
||||
content: ResponsesMessageContent::Text("first".into()),
|
||||
@@ -646,7 +689,7 @@ mod tests {
|
||||
fn image_input_translates_to_chat_parts_array() {
|
||||
let req = ResponsesRequest {
|
||||
model: "m".into(),
|
||||
input: ResponsesInput::Items(vec![ResponsesInputItem::Message {
|
||||
input: typed_items(vec![ResponsesInputItem::Message {
|
||||
role: "user".into(),
|
||||
content: ResponsesMessageContent::Parts(vec![
|
||||
ResponsesContentPart::InputText {
|
||||
@@ -687,7 +730,7 @@ mod tests {
|
||||
// it's dropped — but it must not break translation.
|
||||
let req = ResponsesRequest {
|
||||
model: "m".into(),
|
||||
input: ResponsesInput::Items(vec![ResponsesInputItem::Message {
|
||||
input: typed_items(vec![ResponsesInputItem::Message {
|
||||
role: "user".into(),
|
||||
content: ResponsesMessageContent::Parts(vec![
|
||||
ResponsesContentPart::InputText {
|
||||
@@ -729,7 +772,7 @@ mod tests {
|
||||
fn text_only_parts_collapse_to_string() {
|
||||
let req = ResponsesRequest {
|
||||
model: "m".into(),
|
||||
input: ResponsesInput::Items(vec![ResponsesInputItem::Message {
|
||||
input: typed_items(vec![ResponsesInputItem::Message {
|
||||
role: "user".into(),
|
||||
content: ResponsesMessageContent::Parts(vec![
|
||||
ResponsesContentPart::InputText {
|
||||
@@ -759,7 +802,7 @@ mod tests {
|
||||
fn reasoning_items_are_silently_dropped() {
|
||||
let req = ResponsesRequest {
|
||||
model: "m".into(),
|
||||
input: ResponsesInput::Items(vec![
|
||||
input: typed_items(vec![
|
||||
ResponsesInputItem::Reasoning { content: vec![] },
|
||||
ResponsesInputItem::Message {
|
||||
role: "user".into(),
|
||||
@@ -779,6 +822,74 @@ mod tests {
|
||||
assert_eq!(chat.messages[0].role, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_easy_messages_translate_like_typed_messages() {
|
||||
// The agent-zero / litellm shape: bare `{role, content}` items
|
||||
// with no `type`. Deserialize from raw JSON (not hand-built)
|
||||
// so this exercises the real parse path end to end.
|
||||
let raw = r#"{
|
||||
"model": "Qwen/Qwen3.6-27B",
|
||||
"store": true,
|
||||
"input": [
|
||||
{"role": "system", "content": "be terse"},
|
||||
{"role": "assistant", "content": "{\"tool_name\":\"response\"}"},
|
||||
{"role": "user", "content": "alpha"}
|
||||
]
|
||||
}"#;
|
||||
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
|
||||
let chat = request_to_chat(req).unwrap();
|
||||
let roles: Vec<&str> = chat.messages.iter().map(|m| m.role.as_str()).collect();
|
||||
assert_eq!(roles, vec!["system", "assistant", "user"]);
|
||||
assert!(matches!(
|
||||
&chat.messages[2].content,
|
||||
MessageContent::Text(t) if t == "alpha"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_content_and_unknown_items_survive_translation() {
|
||||
// An assistant turn with `content: null` is kept (empty text);
|
||||
// an unmodeled item type is dropped, not rejected.
|
||||
let raw = r#"{
|
||||
"model": "m",
|
||||
"input": [
|
||||
{"role": "assistant", "content": null},
|
||||
{"type": "item_reference", "id": "x"},
|
||||
{"role": "user", "content": "go"}
|
||||
]
|
||||
}"#;
|
||||
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
|
||||
let chat = request_to_chat(req).unwrap();
|
||||
// assistant(null) kept, item_reference dropped, user kept.
|
||||
let roles: Vec<&str> = chat.messages.iter().map(|m| m.role.as_str()).collect();
|
||||
assert_eq!(roles, vec!["assistant", "user"]);
|
||||
assert!(matches!(
|
||||
&chat.messages[0].content,
|
||||
MessageContent::Text(t) if t.is_empty()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_call_output_array_renders_to_text() {
|
||||
// OpenAI allows `function_call_output.output` to be an array of
|
||||
// content parts; the tool result must reach the model as text.
|
||||
let raw = r#"{
|
||||
"model": "m",
|
||||
"input": [
|
||||
{"type": "function_call_output", "call_id": "c1",
|
||||
"output": [{"type": "output_text", "text": "42"}]}
|
||||
]
|
||||
}"#;
|
||||
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
|
||||
let chat = request_to_chat(req).unwrap();
|
||||
assert_eq!(chat.messages.len(), 1);
|
||||
assert_eq!(chat.messages[0].role, "tool");
|
||||
match &chat.messages[0].content {
|
||||
MessageContent::Text(t) => assert!(t.contains("42"), "got {t:?}"),
|
||||
other => panic!("expected text, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── streaming projector ─────────────────────────────────────────
|
||||
|
||||
async fn collect(mut rx: mpsc::Receiver<ResponseStreamFrame>) -> Vec<ResponseStreamFrame> {
|
||||
@@ -806,6 +917,7 @@ mod tests {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -856,6 +968,7 @@ mod tests {
|
||||
prompt_tokens: 30,
|
||||
completion_tokens: 12,
|
||||
reasoning_tokens: 4,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -886,6 +999,7 @@ mod tests {
|
||||
prompt_tokens: 8,
|
||||
completion_tokens: 3,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -910,6 +1024,7 @@ mod tests {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -956,6 +1071,7 @@ mod tests {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
6
data/helexa-upstream-firewalld.xml
Normal file
6
data/helexa-upstream-firewalld.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<service>
|
||||
<short>helexa-upstream</short>
|
||||
<description>helexa-upstream — mesh account + budget authority API (/authz/v1 for cortex, /web/v1 for the frontend)</description>
|
||||
<port protocol="tcp" port="8090"/>
|
||||
</service>
|
||||
3
data/helexa-upstream-sysusers.conf
Normal file
3
data/helexa-upstream-sysusers.conf
Normal file
@@ -0,0 +1,3 @@
|
||||
g helexa-upstream - -
|
||||
u helexa-upstream - "helexa-upstream authority" /var/lib/helexa-upstream /sbin/nologin
|
||||
m helexa-upstream helexa-upstream
|
||||
22
data/helexa-upstream.service
Normal file
22
data/helexa-upstream.service
Normal file
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=helexa-upstream — mesh account + budget authority (accounts, API keys, allocation ledger, top-up codes)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/helexa-upstream serve --config /etc/helexa-upstream/helexa-upstream.toml
|
||||
# HTTP authority for cortex (/authz/v1) and the frontend (/web/v1); restart
|
||||
# unconditionally if it ever exits. It connects out to PostgreSQL (the
|
||||
# system of record) and runs schema migrations on startup.
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=helexa-upstream
|
||||
Group=helexa-upstream
|
||||
# Service user home; no local state (PostgreSQL holds everything), but
|
||||
# StateDirectory gives the user a writable, correctly-owned home.
|
||||
StateDirectory=helexa-upstream
|
||||
StateDirectoryMode=0755
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -32,3 +32,22 @@ F0 scaffold. Theming + i18n (33 languages, usage-ordered selector), the
|
||||
`/mission` page, the chat workspace (Dexie + streaming), and the account
|
||||
dashboard land in subsequent phases — see
|
||||
`~/.claude/plans/we-need-to-plan-modular-graham.md`.
|
||||
|
||||
## Deploy (public beta)
|
||||
|
||||
Build the SPA and serve it from edge nginx on the **same origin** as the
|
||||
two backends — so the browser makes no cross-origin request (no CORS) and
|
||||
the user's API key rides as a first-party bearer.
|
||||
|
||||
```sh
|
||||
npm ci && npm run build # → dist/
|
||||
sudo cp -r dist/* /var/www/helexa.ai/
|
||||
sudo cp deploy/nginx.conf /etc/nginx/conf.d/helexa.ai.conf # adjust upstreams + TLS
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
`deploy/nginx.conf` routes `/` → SPA (history fallback), `/v1` + `/health`
|
||||
→ helexa-router, and `/api/` → helexa-upstream `/web/v1/`. Set
|
||||
`VITE_PUBLIC_BETA=true` at build time for the beta banner. There is **no
|
||||
server-side chat history**: conversations live only in the browser
|
||||
(IndexedDB).
|
||||
|
||||
60
helexa.ai/deploy/nginx.conf
Normal file
60
helexa.ai/deploy/nginx.conf
Normal file
@@ -0,0 +1,60 @@
|
||||
# helexa.ai — edge nginx for the public beta.
|
||||
#
|
||||
# Serves the built SPA (helexa.ai/dist) and reverse-proxies the two
|
||||
# backends on the SAME ORIGIN, so the browser never makes a cross-origin
|
||||
# request: no CORS, and the user's API key rides as a first-party bearer.
|
||||
#
|
||||
# / → static SPA (history fallback to index.html)
|
||||
# /v1, /health → helexa-router (OpenAI-compatible inference data-plane)
|
||||
# /api/ → helexa-upstream /web/v1/ (account control-plane)
|
||||
#
|
||||
# TLS is terminated here (certs omitted — wire up certbot / your CA). The
|
||||
# upstream hosts below are examples; point them at your router/upstream.
|
||||
|
||||
upstream helexa_router { server 127.0.0.1:8088; }
|
||||
upstream helexa_upstream { server 127.0.0.1:8090; }
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name helexa.ai;
|
||||
|
||||
# ssl_certificate /etc/letsencrypt/live/helexa.ai/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/helexa.ai/privkey.pem;
|
||||
|
||||
root /var/www/helexa.ai;
|
||||
index index.html;
|
||||
|
||||
# Long-cache fingerprinted assets; never cache the HTML shell.
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Inference data-plane → router. Streaming (SSE): disable buffering so
|
||||
# tokens reach the browser as they arrive.
|
||||
location /v1/ {
|
||||
proxy_pass http://helexa_router;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
location = /health {
|
||||
proxy_pass http://helexa_router;
|
||||
}
|
||||
|
||||
# Account control-plane → upstream /web/v1/ (strip the /api prefix).
|
||||
location /api/ {
|
||||
proxy_pass http://helexa_upstream/web/v1/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# SPA history fallback: anything else serves index.html.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -409,3 +409,15 @@ pre {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Public-beta banner (F6) — slim accent strip above the header. */
|
||||
.beta-banner {
|
||||
background: var(--bs-warning-bg-subtle, #fff3cd);
|
||||
color: var(--bs-warning-text-emphasis, #664d03);
|
||||
border-bottom: 1px solid var(--bs-warning-border-subtle, #ffe69c);
|
||||
}
|
||||
[data-bs-theme="dark"] .beta-banner {
|
||||
background: rgba(255, 193, 7, 0.12);
|
||||
color: #ffda6a;
|
||||
border-bottom-color: rgba(255, 193, 7, 0.25);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import AuthProvider from "./auth/AuthProvider";
|
||||
import RequireAuth from "./auth/RequireAuth";
|
||||
import Header from "./components/Header";
|
||||
import Footer from "./components/Footer";
|
||||
import BetaBanner from "./components/BetaBanner";
|
||||
import Mission from "./pages/Mission";
|
||||
import Chat from "./pages/Chat";
|
||||
import Login from "./pages/auth/Login";
|
||||
@@ -24,6 +25,7 @@ export default function App() {
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<div className="d-flex flex-column min-vh-100">
|
||||
<BetaBanner />
|
||||
<Header />
|
||||
<Routes>
|
||||
<Route path="/" element={<Chat />} />
|
||||
|
||||
35
helexa.ai/src/components/BetaBanner.tsx
Normal file
35
helexa.ai/src/components/BetaBanner.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Slim public-beta notice shown above the header when VITE_PUBLIC_BETA is
|
||||
* set. Dismissible for the session (sessionStorage) so it doesn't nag, but
|
||||
* returns on the next visit while the beta lasts.
|
||||
*/
|
||||
const SHOWN = import.meta.env.VITE_PUBLIC_BETA === "true";
|
||||
const DISMISS_KEY = "helexa.betaDismissed";
|
||||
|
||||
export default function BetaBanner() {
|
||||
const { t } = useTranslation("common");
|
||||
const [hidden, setHidden] = useState(
|
||||
() => sessionStorage.getItem(DISMISS_KEY) === "1",
|
||||
);
|
||||
if (!SHOWN || hidden) return null;
|
||||
return (
|
||||
<div className="beta-banner d-flex align-items-center justify-content-center gap-2 px-3 py-1 small">
|
||||
<span>
|
||||
<strong>{t("beta.tag")}</strong> {t("beta.message")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close btn-close-white ms-2"
|
||||
style={{ fontSize: "0.6rem" }}
|
||||
aria-label={t("beta.dismiss")}
|
||||
onClick={() => {
|
||||
sessionStorage.setItem(DISMISS_KEY, "1");
|
||||
setHidden(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
|
||||
109
rpm/helexa-upstream-prerelease.spec
Normal file
109
rpm/helexa-upstream-prerelease.spec
Normal file
@@ -0,0 +1,109 @@
|
||||
# Prebuilt-binary spec for helexa-upstream.
|
||||
#
|
||||
# Wraps a pre-built `helexa-upstream` binary produced by an upstream CI
|
||||
# job and packages it for rpm.lair.cafe. The %build phase is a no-op.
|
||||
# helexa-upstream is a pure-Rust, non-CUDA daemon: the mesh account +
|
||||
# budget authority. It serves an inbound HTTP API on tcp/8090 (/authz/v1
|
||||
# for cortex, /web/v1 for the frontend) — hence the firewalld service —
|
||||
# and connects out to PostgreSQL (the system of record), running schema
|
||||
# migrations on startup.
|
||||
#
|
||||
# Required defines at rpmbuild time:
|
||||
# upstream_version e.g. "0.1.16"
|
||||
# upstream_prerelease e.g. "0.1.20260518140530.gitabcdef0"
|
||||
# ^^^^^^^^^^^^^^^^^^ ^^^^^^^^
|
||||
# commit time (sec) commit sha
|
||||
# (used as Release; the timestamp prefix
|
||||
# keeps same-day builds strictly ordered.)
|
||||
|
||||
%global _build_id_links none
|
||||
%global debug_package %{nil}
|
||||
%global __strip /usr/bin/true
|
||||
|
||||
%{!?upstream_version: %global upstream_version 0.0.0}
|
||||
%if 0%{?upstream_prerelease:1}
|
||||
%global upstream_release %{upstream_prerelease}
|
||||
%else
|
||||
%global upstream_release 1
|
||||
%endif
|
||||
|
||||
Name: helexa-upstream
|
||||
Version: %{upstream_version}
|
||||
Release: %{upstream_release}%{?dist}
|
||||
Summary: Mesh account + budget authority for helexa (prebuilt)
|
||||
|
||||
License: GPL-3.0-or-later
|
||||
URL: https://git.lair.cafe/helexa/helexa
|
||||
|
||||
Source0: helexa-upstream
|
||||
Source1: helexa-upstream.service
|
||||
Source2: helexa-upstream-sysusers.conf
|
||||
Source3: helexa-upstream.example.toml
|
||||
Source4: LICENSE
|
||||
Source5: helexa-upstream-firewalld.xml
|
||||
|
||||
Requires: firewalld-filesystem
|
||||
|
||||
ExclusiveArch: x86_64
|
||||
|
||||
Requires(pre): shadow-utils
|
||||
Requires: systemd
|
||||
|
||||
Provides: user(helexa-upstream)
|
||||
|
||||
%description
|
||||
helexa-upstream is the mesh-level authority: it issues user accounts and
|
||||
API keys, holds the allocation ledger (free grant + redeemable top-up
|
||||
codes), enforces per-key budgets via a transactional reserve/settle
|
||||
contract, and reconciles served usage for operator compensation. cortex
|
||||
validates locally-unrecognised keys against its /authz/v1 surface
|
||||
(fail-closed); the helexa.ai frontend drives account self-service via
|
||||
/web/v1. PostgreSQL is the system of record; the schema is migrated on
|
||||
startup.
|
||||
|
||||
%prep
|
||||
cp %{SOURCE0} ./helexa-upstream
|
||||
cp %{SOURCE1} .
|
||||
cp %{SOURCE2} .
|
||||
cp %{SOURCE3} .
|
||||
cp %{SOURCE4} .
|
||||
cp %{SOURCE5} .
|
||||
|
||||
%build
|
||||
# Already built in the upstream CI build job.
|
||||
|
||||
%install
|
||||
install -Dm755 helexa-upstream %{buildroot}%{_bindir}/helexa-upstream
|
||||
install -Dm644 helexa-upstream.service %{buildroot}%{_unitdir}/helexa-upstream.service
|
||||
install -Dm644 helexa-upstream-sysusers.conf %{buildroot}%{_sysusersdir}/helexa-upstream.conf
|
||||
install -Dm644 helexa-upstream-firewalld.xml %{buildroot}%{_prefix}/lib/firewalld/services/helexa-upstream.xml
|
||||
install -dm755 %{buildroot}%{_sysconfdir}/helexa-upstream
|
||||
install -Dm644 helexa-upstream.example.toml %{buildroot}%{_sysconfdir}/helexa-upstream/helexa-upstream.toml
|
||||
|
||||
%pre
|
||||
getent group helexa-upstream >/dev/null || groupadd -r helexa-upstream
|
||||
getent passwd helexa-upstream >/dev/null || \
|
||||
useradd -r -g helexa-upstream -d /var/lib/helexa-upstream -s /sbin/nologin \
|
||||
-c "helexa-upstream authority" helexa-upstream
|
||||
|
||||
%post
|
||||
%systemd_post helexa-upstream.service
|
||||
|
||||
%preun
|
||||
%systemd_preun helexa-upstream.service
|
||||
|
||||
%postun
|
||||
%systemd_postun_with_restart helexa-upstream.service
|
||||
|
||||
%files
|
||||
%license LICENSE
|
||||
%{_bindir}/helexa-upstream
|
||||
%{_unitdir}/helexa-upstream.service
|
||||
%{_sysusersdir}/helexa-upstream.conf
|
||||
%{_prefix}/lib/firewalld/services/helexa-upstream.xml
|
||||
%dir %{_sysconfdir}/helexa-upstream
|
||||
%config(noreplace) %{_sysconfdir}/helexa-upstream/helexa-upstream.toml
|
||||
|
||||
%changelog
|
||||
* Mon Jun 23 2026 Gitea Actions <actions@git.lair.cafe> - %{upstream_version}-%{upstream_release}
|
||||
- Prerelease build from upstream CI binary.
|
||||
Reference in New Issue
Block a user