Skip to content

feat(backend-native, server-core): support the chatCompletion config hook - #11733

Open
paveltiunov wants to merge 4 commits into
masterfrom
claude/llm-gateway-byom-ytuaf7
Open

feat(backend-native, server-core): support the chatCompletion config hook#11733
paveltiunov wants to merge 4 commits into
masterfrom
claude/llm-gateway-byom-ytuaf7

Conversation

@paveltiunov

Copy link
Copy Markdown
Member

Check List

  • Tests have been run in packages where changes have been made if available
  • Linter has been run for changed code
  • Tests for the changes have been added if not covered yet
  • Docs have been added / updated if required

Description of Changes Made

Lets a deployment declare a chatCompletion / chat_completion config hook, which Cube Cloud's LLM Gateway BYOM provider calls to route agent inference through the deployment instead of dialling a model vendor from the control plane. Cube Core accepts the option and ignores it.

Four registries define a config option, and each needed an entry for a different reason:

File Why
python/cube/src/__init__.py Configuration.chat_completion, so @config binds it
src/python/cube_config.rs The allow-list that decides whether a cube.py attribute reaches JS at all — an unlisted name is dropped silently, with no error pointing at the omission
core/optionsValidate.ts The schema rejects unknown keys, so without an entry every deployment declaring the hook fails to boot with Invalid cube-server-core options
core/types.ts So a cube.ts config that sets it type-checks. Typed unknown on purpose: the value may be a chat model instance, a factory or a stream of chunks, and Core has no dependency on the model library that would let it name any of those

The Python and JavaScript contracts differ, and the tests pin why rather than leaving it as folklore. The bridge carries scalars, lists, dicts and plain functions, so a Python hook returns a list of chunks or a {"next": fn} closure; a model object throws Unable to represent PyObject in JS, which the third test asserts.

One finding worth a reviewer's attention. CLRepr::Null converts to cx.undefined(), so the return None that ends a Python stream arrives in JavaScript as undefined, never as null. The first draft of the pull-stream test looped until null and hung — which is exactly what anyone writing the consumer from the Python side would do. a Python None crosses as undefined, not null pins it.

Testing

Ran against a debug build of the native module with --features python (cargo build --features python), since the Rust allow-list and the embedded Python module ship in one artifact and neither is exercised without it:

  • packages/cubejs-backend-nativejest dist/test/python.test.js: 14 passed, 1 skipped (the darwin-only suite). Includes the three new chat_completion cases and the None conversion case.
  • packages/cubejs-server-corejest dist/test/unit/optionsValidate.test.js: 12 passed, optionsValidate.ts at 100% statement coverage.
  • yarn tsc from the repo root: clean.

Consumers

The runtime and Cloud halves are on the same branch name in cubedevinc/cube-runtime and cubedevinc/cubejs-enterprise. Nothing in this PR depends on them — Core accepts the option and ignores it either way.

🤖 Generated with Claude Code

https://claude.ai/code/session_016hUrmyUJnJ9ta9aGM6oi7H


Generated by Claude Code

Documents the `chatCompletion` config hook the LLM Gateway provider calls,
with the LangChain and plain async-iterable forms, the request fields the
hook receives, and the troubleshooting cases specific to it.

Also corrects the Network configuration section, which said Cube always
connects to the model provider from its control plane. That is now true of
every provider except this one — the LLM Gateway request is made by the
customer's own deployment, so a gateway with no public ingress needs no
allowlisting or peering. That inversion is the reason the provider exists,
so the old wording would have sent readers looking for an allowlist that
does not apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016hUrmyUJnJ9ta9aGM6oi7H
…hook

Lets a deployment declare the `chat_completion` hook that Cube Cloud's LLM
Gateway model provider calls, and stops Core rejecting its camelCase
counterpart outright.

Three changes, each needed for a different reason:

- `Configuration.chat_completion` plus `"chat_completion"` in the
  `cube_config.rs` allow-list. That list is what decides whether a
  `cube.py` attribute reaches JavaScript at all — an unlisted name is
  dropped in silence, with no error pointing at the omission. Both halves
  ship in one native artifact, since the Python module is embedded with
  `include_str!`.
- `chatCompletion` in `optionsValidate`. The schema rejects unknown keys,
  so without an entry every deployment declaring the hook fails to start
  with "Invalid cube-server-core options". Core does not read the option;
  it accepts and ignores it.
- `CoreCreateOptions.chatCompletion`, so a `cube.ts` config that sets it
  type-checks. Typed as `unknown` deliberately: the value may be a chat
  model instance, a factory, or a stream of chunks, and Core has no
  dependency on the model library that would let it name any of those.

The Python contract is narrower than the JavaScript one and the tests
pin why: the bridge carries scalars, lists, dicts and plain functions, so
a hook returns a list of chunks or a `{"next": fn}` closure — a model
object throws, which the third test asserts rather than leaves as folklore.

Docs cover both languages, including the chunk format a hand-written hook
needs. Agents call tools every turn, so a hook that only emits `content`
cannot serve one; that was worth stating outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016hUrmyUJnJ9ta9aGM6oi7H
Running the chat_completion pull-stream case against a real build of the
native module turned up an asymmetry worth a test of its own:
`CLRepr::Null` converts to `cx.undefined()`, so the `return None` that
ends a Python stream arrives in JavaScript as `undefined` and never as
`null`.

The first version of this test looped until `null` and hung, which is
exactly what a consumer written from the Python side would do — nothing
about writing `return None` suggests the other side sees anything else.
Pinned directly so the conversion cannot drift, and so the next reader
finds the answer instead of a timeout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016hUrmyUJnJ9ta9aGM6oi7H
@paveltiunov
paveltiunov requested review from a team and keydunov as code owners September 2, 2026 04:47
@mintlify

mintlify Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
cubed3 🟢 Ready View Preview Sep 2, 2026, 4:48 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@github-actions github-actions Bot added rust Pull requests that update Rust code javascript Pull requests that update Javascript code python labels Sep 2, 2026
@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @paveltiunov's task in 2m 26s —— View job


Review: 1 medium, 5 low — solid, additive change; main asks are a doc/behaviour mismatch and comment volume

Full review (6 inline comments)

Todos

  • Read the diff against origin/master
  • Trace how chat_completion actually reaches CreateOptions
  • Check the Joi schema and its tests
  • Check docs accuracy against the pinned behaviour
  • Post inline comments and summary

What holds up

The wiring is complete and I traced it end to end: cube_config.rs allow-list → attr() snake→camel via to_case(Case::Camel)pythonLoadConfigcontainer.ts:350 returns the whole object as CreateOptionsvalidateOptions. Nothing drops the key on the way. chat_completion also lands in the right alphabetical slot in the allow-list (can_switch_sql_user, chat_completion, check_auth), and Any is already imported in __init__.py. Core reads the option nowhere, which matches the stated "accepts and ignores" contract — no behaviour change for OSS deployments.

The a Python None crosses as undefined, not null test is the best thing in the PR. CLRepr::Null → cx.undefined() is exactly the kind of asymmetry that costs someone an afternoon, and pinning it is worth more than the prose about it.

No security concerns: the hook is customer code in the customer's own config file, credentials stay in deployment env vars, and the change adds no new network path. No performance concerns.

Findings

# Where Severity Issue
1 bring-your-own-model.mdx:351 Medium Troubleshooting says a cube.py model object yields "Must return a LangChain chat model, …", but the new test pins the bridge throwing Unable to represent PyObject in JS first. The message a user actually sees isn't in the docs
2 js/index.ts:540 Low JSDoc says next() resolves to null at end of stream — the companion test pins undefined. Contradicts the thing the PR went to the trouble of pinning
3 optionsValidate.test.ts:59 Low Reference-preservation is only asserted for the function branch; the object branch (class instance through Joi.object()) is the one that could silently lose its prototype
4 types.ts:255 Low chatCompletion?: unknown forces narrowing at every read site; ((request: any) => any) | object matches what the schema already accepts without adding a dependency
5 types.ts / optionsValidate.ts / js/index.ts / __init__.py / cube_config.rs Low The same LLM-Gateway paragraph is restated in five places, 5–11 lines each. Four copies to keep in sync with a docs page that says it better
6 python.test.ts Low Test prefaces run 5–6 lines. The None/undefined one earns it; the others largely restate the assertion below them

The docs page itself reads well and the chunk-format section is the part a customer will actually need. Two claims I could verify are correct: cube.py does take precedence over cube.js (container.ts:270 before :295), and the schema really does reject unknown keys, so the optionsValidate.ts entry is load-bearing rather than defensive.

I could not run the native tests here — reproducing them needs cargo build --features python, and the PR description reports 14 passed / 1 skipped, which I took at face value.

· branch `claude/llm-gateway-byom-ytuaf7`

Comment thread docs-mintlify/admin/ai/bring-your-own-model.mdx Outdated
Comment thread packages/cubejs-server-core/src/core/optionsValidate.ts Outdated
Comment thread packages/cubejs-server-core/test/unit/optionsValidate.test.ts Outdated
Comment thread packages/cubejs-server-core/src/core/types.ts
Comment thread packages/cubejs-backend-native/js/index.ts
Comment thread packages/cubejs-backend-native/python/cube/src/__init__.py Outdated
Two real defects and three trims.

**The JSDoc in `js/index.ts` said the stream terminator was `null`** — the
exact mistake the `a Python None crosses as undefined, not null` test
exists to prevent, left standing in the one file a consumer types
against. Anyone writing `if (chunk === null) break` from it would hang.

**The docs pointed at an error users will never see.** A `cube.py` hook
returning a model object throws `Unable to represent PyObject in JS` at
the bridge, before the return value is ever inspected for a stream, so
the "Must return a LangChain chat model, …" entry could not match it.
That failure now has its own entry with the message it actually produces.

`preserves the hook by reference` only covered the function branch, which
Joi cannot rewrite anyway. The branch worth pinning is the object one:
`validateOptions` returns Joi's `value`, so a clone would strip a model
instance's prototype and with it `bindTools` — reported downstream as
"does not support tool calling". Now asserted with a class instance,
which an object literal could not have shown. Confirms Joi preserves it.

The same explanation had been written out in five places and would have
drifted independently. Each now keeps only the fact unique to it and
points at the docs page for the rest.

`chatCompletion` stays `unknown` rather than `((request: any) => any) |
object`: nothing in Core reads it, so no consumer has to narrow it, and
an author assigning to it type-checks either way. `object` already admits
functions in TypeScript, so the wider type buys little and costs an `any`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016hUrmyUJnJ9ta9aGM6oi7H
@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @paveltiunov's task in 1m 7s —— View job


Re-review: all 6 prior findings addressed, no new blocking issues — 1 low (comment length in tests)

Full re-review

Todos

  • Read the updated diff against origin/master
  • Check which of the 6 prior threads are addressed
  • Resolve my stale threads (all 6 resolved)
  • Look for new issues in 6654fec
  • Post summary

What 6654fec fixed

Prior finding Status
1 — docs pointed at an error cube.py never produces Fixed. Unable to represent PyObject in JS now has its own bullet naming the message the bridge actually raises, and the LangChain-model bullet is scoped to cube.js. This matches what config-chat-completion-object.py pins
2 — 7-line comment in optionsValidate.ts Trimmed to 4 lines; keeps only "unknown keys are rejected" + why an object is allowed, and links the docs page
3 — reference-preservation only covered the function branch Fixed properly. preserves a model instance by reference, prototype intact uses a class instance and asserts toBeInstanceOf plus a live bindTools — that's the assertion an object literal could not have made
4 — types.ts JSDoc length / unknown typing Comment trimmed to 4 lines. unknown kept, with a rationale I agree with on reflection: object admits functions in TypeScript, so the union buys almost nothing and costs an any, and no Core code reads the field so nobody is forced to narrow
5 — same paragraph in five places Each site now carries only its unique fact: the allow-list comment says silent-drop, the Python one says bridge restriction, the Joi one says unknown-key rejection
6 — js/index.ts JSDoc said the terminator was null Fixed, and the replacement is better than my suggestion — it names the cause (Noneundefined) and the symptom (if (chunk === null) break never terminates) in the same breath

Remaining, low

The test prefaces in python.test.ts are still 4–5 lines each (the allow-list one at the top of the suite, and the None/undefined one). Two of the three earn it — silent attribute drop and the undefined terminator are both facts a reader cannot get from the code below them. The chat_completion returning an arbitrary Python object is rejected preface mostly restates its own assertion and could lose a line or two. Not worth another round on its own.

Verification

Re-traced the wiring after the edits: allow-list entry still in its alphabetical slot, chatCompletion: expect.any(Function) still asserted in the full-config shape test, Joi entry unchanged in behaviour (only the comment above it moved). expect(terminator).not.toBeNull() is a real assertion here rather than a tautology — Jest's toBeNull is ===, so it would fail if the bridge ever started emitting null.

As before, I could not run the native suite in this environment — it needs cargo build --features python. The PR reports 14 passed / 1 skipped and 12 passed for optionsValidate; the diff is consistent with that and I took the counts at face value.

No security or performance concerns: still customer code in the customer's own config file, no new network path, and Core reads the option nowhere.

· branch `claude/llm-gateway-byom-ytuaf7`

@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.64%. Comparing base (6c75c60) to head (6654fec).

Additional details and impacted files
@@             Coverage Diff             @@
##           master   #11733       +/-   ##
===========================================
+ Coverage   59.08%   84.64%   +25.55%     
===========================================
  Files         237      261       +24     
  Lines       19022    86101    +67079     
  Branches     3865        0     -3865     
===========================================
+ Hits        11239    72876    +61637     
- Misses       7222    13225     +6003     
+ Partials      561        0      -561     
Flag Coverage Δ
cube-backend ?
cubesql 84.64% <ø> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Member Author

Upload merged coverage to Codecov is red — not this PR's, with a proposed fix

Failed twice (initial run and one re-run, run 33592701677). My re-run allowance is spent, so here's the root cause rather than another retry.

What actually fails

The Download all coverage artifacts step runs actions/download-artifact@v4 with no pattern, so it logs No input name, artifact-ids or pattern filtered specified, downloading all artifacts and pulls all 20 artifacts in the run. Nineteen download successfully. Exactly one does not, on both attempts:

- cube-js~cube~UVHI5D.dockerbuild (ID: 9832702639, Size: 120627)

That's a docker/build-push-action build record — not a coverage artifact. Every coverage-* artifact, including coverage-unit where this PR's new tests land, downloaded fine. Codecov's own bot separately posted a clean report on this PR (84.64%, all modified lines covered), so the coverage data itself was collected and uploaded; it's only this merged-aggregate job that dies.

Why it isn't this PR's

The diff touches cubejs-backend-native (Python config + Rust allow-list), optionsValidate/types in cubejs-server-core, their tests, and a docs page. It adds no artifact, uploads none, and doesn't touch .github/workflows/push.yml. The artifact that fails is produced by the Docker build job and is unrelated to anything here.

Proposed patch (not applied — it would widen this PR)

The next step is find all-coverage -name '*.lcov', so the non-coverage artifacts are downloaded and then ignored. Filtering makes the job both correct and much cheaper — it currently pulls ~73 MB it never reads (cubestored-… 49 MB, native-linux-x64-…node 23 MB, plus the .dockerbuild record):

      - name: Download all coverage artifacts
        uses: actions/download-artifact@v4
        with:
          path: all-coverage
          pattern: coverage-*

This is latent for every PR in the repo, not just this one: any transient blob failure on any non-coverage artifact fails the coverage upload. Happy to open it as its own PR if that's wanted — it seemed wrong to fold a CI change into a feature PR unasked.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

javascript Pull requests that update Javascript code python rust Pull requests that update Rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants