Skip to content

Conversation

@yiphei
Copy link
Contributor

@yiphei yiphei commented Jan 7, 2026

Support overriding the default system provider of openai in instrument_openai to e.g. openrouter. This is necessary to get features like automatic cost computation. For more context: https://pydanticlogfire.slack.com/archives/C06EDRBSAH3/p1767644423267469?thread_ts=1759944637.702099&cid=C06EDRBSAH3

Note: genai_prices.calc_price does not support extraction logic for Openrounter. So the try block that calls genai_prices.calc_price in on_response will error with the following

Traceback (most recent call last):
  File "/Users/yifeiyan/Dev/logfire/logfire/_internal/integrations/llm_providers/openai.py", line 199, in on_response
    usage_data = extract_usage(
        response_data,
        provider_id=model_provider,
        api_flavor='responses' if isinstance(response, Response) else 'chat',
    )
  File "/Users/yifeiyan/Dev/logfire/.venv/lib/python3.13/site-packages/genai_prices/__init__.py", line 94, in extract_usage
    return data_snapshot.get_snapshot().extract_usage(response_data, provider_id, provider_api_url, api_flavor)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/yifeiyan/Dev/logfire/.venv/lib/python3.13/site-packages/genai_prices/data_snapshot.py", line 75, in extract_usage
    model_ref, usage = provider.extract_usage(response_data, api_flavor=api_flavor)
                       ~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/yifeiyan/Dev/logfire/.venv/lib/python3.13/site-packages/genai_prices/types.py", line 315, in extract_usage
    raise ValueError('No extraction logic defined for this provider')
ValueError: No extraction logic defined for this provider

This wont be addressed in this PR because: a) genai_prices is a separate package, so out of scope, and b) client-side computation is not necessary to see the cost info in the UI since that is also computed server-side, though you do lose explicit setting of operation.cost attribute

Run this to check things works as expected

from openai import AsyncOpenAI
import logfire

logfire.configure(
    scrubbing=False,
    console=False,
)
client = AsyncOpenAI(**{
        "base_url": "https://openrouter.ai/api/v1",
        "api_key": <KEY>,
    })
logfire.instrument_openai(client, model_provider='openrouter')

with logfire.span("parent test"):
    await client.chat.completions.create(
        model="gpt-4.1",
        messages=[{"role": "user", "content": "hi, how are you"}],
    )

@yiphei yiphei marked this pull request as draft January 7, 2026 21:35
return cast('ResponseT', response)

span.set_attribute('gen_ai.system', 'openai')
model_provider: str = cast(str, (span.attributes or {}).get('overridden_model_provider', "openai"))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

span.attributes won't work when the span isn't recording

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexmojaki then how should i check if overridden_model_provider is set, or, as you proposed, check that gen_ai.system hasnt already been set upstream

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use getattr(span, 'attributes', None)


span_data['async'] = is_async
if model_provider is not None:
span_data['overridden_model_provider'] = model_provider
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rather just set gen_ai.system (and gen_ai.provider.name, the new attribute) here

get_endpoint_config_fn: Callable[[Any], EndpointConfig],
on_response_fn: Callable[[Any, LogfireSpan], Any],
is_async_client_fn: Callable[[type[Any]], bool],
model_provider: str | None = None,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just call it provider

usage_data = extract_usage(
response_data,
provider_id='openai',
provider_id=model_provider,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think leave this as is, since we're using the openai client we should be able to assume the shape of the usage.

@yiphei
Copy link
Contributor Author

yiphei commented Jan 8, 2026

@alexmojaki addressed your feedback. can you quickly review it again before i add tests?

return cast('ResponseT', response)

span.set_attribute('gen_ai.system', 'openai')
if getattr(span, 'attributes', None) is None or (span.attributes or {}).get('gen_ai.system', None) is None:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexmojaki type checking complained that span.attributes could be None, so i had to do (span.attributes or {})

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if getattr(span, 'attributes', None) is None or (span.attributes or {}).get('gen_ai.system', None) is None:
if not (getattr(span, 'attributes', None) or {}).get('gen_ai.system'):

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doing this will incur the following type checker error

  /Users/yifeiyan/Dev/logfire/logfire/_internal/integrations/llm_providers/openai.py:186:12 - error: Type of "get" is partially unknown
    Type of "get" is "Any | Overload[(key: Unknown, default: None = None, /) -> (Unknown | None), (key: Unknown, default: Unknown, /) -> Unknown, (key: Unknown, default: _T@get, /) -> (Unknown | _T@get)]" (reportUnknownMemberType)

do you want me to suppress it or cast it? the most compact i can make it is

if (getattr(span, 'attributes', {})).get('gen_ai.system', None) is None:

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that works too

@alexmojaki
Copy link
Contributor

@alexmojaki addressed your feedback. can you quickly review it again before i add tests?

no, please get CI passing first

@yiphei yiphei marked this pull request as ready for review January 8, 2026 20:51
@codecov
Copy link

codecov bot commented Jan 8, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@yiphei
Copy link
Contributor Author

yiphei commented Jan 8, 2026

@alexmojaki addressed your feedback. can you quickly review it again before i add tests?

no, please get CI passing first

@alexmojaki now everything passes except for code coverage and linter that complains about missing docstring for the new param. can you take a quick look now?

@alexmojaki
Copy link
Contributor

no, please get CI passing first, and get a review from copilot if possible

@yiphei
Copy link
Contributor Author

yiphei commented Jan 9, 2026

no, please get CI passing first, and get a review from copilot if possible

@alexmojaki the reason im nagging you is because this PR contains an API change of a public method (though a backwards compatible one). Usually, orgs are pretty protective about public API changes, so i dont like to do a much of work and then revert because they dislike the API changes. Therefore, all im asking you is a) to quickly confirm that the API change looks good, or b) to tell me that the specific API changes here are no big deal and should proceed without worries

@alexmojaki
Copy link
Contributor

Yes it looks fine

@yiphei
Copy link
Contributor Author

yiphei commented Jan 10, 2026

@alexmojaki all the tests have been added, and all the CI checks pass. Thanks for the patience.

return cast('ResponseT', response)

span.set_attribute('gen_ai.system', 'openai')
if getattr(span, 'attributes', None) is None or (span.attributes or {}).get('gen_ai.system', None) is None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if getattr(span, 'attributes', None) is None or (span.attributes or {}).get('gen_ai.system', None) is None:
if not (getattr(span, 'attributes', None) or {}).get('gen_ai.system'):

@yiphei yiphei requested a review from alexmojaki January 12, 2026 17:36
@yiphei
Copy link
Contributor Author

yiphei commented Jan 12, 2026

Note: starting recently, running the test suite locally always fails with the same following errors

..................................................................................................................................................................................... [ 20%]
............................................F                                                                                                                                         [ 25%]tests/otel_integrations/test_sqlalchemy.py:389 test_sqlalchemy_async_instrumentation[engine] - FileNotFoundError: [Errno 2] No such file or directory: 'example2.db'…. [ 25%]
.........................................F                                                                                                                                            [ 29%]tests/test_cli.py:178 test_whoami_no_token_no_url - Failed: DID NOT RAISE <class 'SystemExit'>…. [ 29%]
.................................................................................F                                                                                                    [ 38%]tests/test_configure.py:1362 test_send_to_logfire_if_token_present_empty_via_env_var - assert 1 == 0  +  where 1 = len([<requests_mock.request._RequestObjectProxy object at 0x14…. [ 39%]
.F                                                                                                                                                                                    [ 39%]tests/test_configure.py:1410 test_send_to_logfire_if_token_present_not_empty_via_env_with_empty_arg - AssertionError: assert '' == 'Logfire proj...project_url\n'      - Logfire …. [ 39%]
..E                                                                                                                                                                                   [ 39%]tests/test_configure.py:1362 test_send_to_logfire_if_token_present_empty_via_env_var - pytest.PytestUnhandledThreadExceptionWarning: Exception in thread check_logfire_token Enab…. [ 39%]
.E                                                                                                                                                                                    [ 39%]tests/test_configure.py:1410 test_send_to_logfire_if_token_present_not_empty_via_env_with_empty_arg - pytest.PytestUnhandledThreadExceptionWarning: Exception in thread check_log…. [ 39%]
.............F                                                                                                                                                                        [ 41%]tests/test_configure.py:1340 test_send_to_logfire_if_token_present - AssertionError: assert 'pylf_v1_us_d9kndG8CDtLQCR6dpg3yNFSX2vbFsx9BcgTlvGLsVzQF' is None  +  where 'pylf_v1_…. [ 41%]
......E                                                                                                                                                                               [ 42%]tests/test_configure.py:1340 test_send_to_logfire_if_token_present - pytest.PytestUnhandledThreadExceptionWarning: Exception in thread check_logfire_token Enable tracemalloc to …. [ 42%]
.....F                                                                                                                                                                                [ 43%]tests/test_configure.py:1349 test_send_to_logfire_if_token_present_empty - assert 1 == 0  +  where 1 = len([<requests_mock.request._RequestObjectProxy object at 0x13d9b6200>])  …. [ 43%]
.......E                                                                                                                                                                              [ 43%]tests/test_configure.py:1349 test_send_to_logfire_if_token_present_empty - pytest.PytestUnhandledThreadExceptionWarning: Exception in thread check_logfire_token Enable tracemall…. [ 44%]
.........F                                                                                                                                                                            [ 45%]tests/test_configure.py:1394 test_send_to_logfire_if_token_present_not_empty - AssertionError: assert '' == 'Logfire proj...project_url\n'      - Logfire project URL: fake_proje…F [ 45%]
                                                                                                                                                                                      [ 45%]tests/test_configure.py:1449 test_configure_unknown_token_region - AssertionError: assert '' == 'Logfire proj...ydantic.dev\n'      - Logfire project URL: https://logfire-us.pyd…. [ 45%]
F                                                                                                                                                                                     [ 45%]tests/test_configure.py:1379 test_send_to_logfire_if_token_present_empty_via_arg - assert 1 == 0  +  where 1 = len([<requests_mock.request._RequestObjectProxy object at 0x147a30…. [ 45%]
......E                                                                                                                                                                               [ 46%]tests/test_configure.py:1394 test_send_to_logfire_if_token_present_not_empty - pytest.PytestUnhandledThreadExceptionWarning: Exception in thread check_logfire_token Enable trace…. [ 46%]
...E                                                                                                                                                                                  [ 46%]tests/test_configure.py:1449 test_configure_unknown_token_region - pytest.PytestUnhandledThreadExceptionWarning: Exception in thread check_logfire_token Enable tracemalloc to ge…. [ 46%]
.E                                                                                                                                                                                    [ 46%]tests/test_configure.py:1379 test_send_to_logfire_if_token_present_empty_via_arg - pytest.PytestUnhandledThreadExceptionWarning: Exception in thread check_logfire_token Enable t…. [ 47%]
..............................F                                                                                                                                                       [ 50%]tests/test_configure.py:589 test_configure_export_delay - ExceptionGroup: multiple thread exception warnings (2 sub-exceptions)…. [ 50%]
..................................................................................................................................................................................... [ 70%]
.....................................................................................s.......................s.......s............................................................... [ 90%]
.x..x....s..s..s.s..s.s.............................................................

I did everything as outlined in https://github.com/pydantic/logfire/blob/main/CONTRIBUTING.md

@alexmojaki
Copy link
Contributor

You probably have a .logfire directory in the repo with credentials

@yiphei
Copy link
Contributor Author

yiphei commented Jan 12, 2026

@alexmojaki you didnt submit a review last time, so re-requesting your review via this comment

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds support for overriding the default model provider in instrument_openai to enable proper attribution and cost calculation for OpenAI-compatible providers like OpenRouter.

Changes:

  • Added override_provider parameter to instrument_openai() method with comprehensive documentation
  • Modified instrument_llm_provider() to accept and use override_provider to set the gen_ai.system attribute
  • Updated on_response() to conditionally set gen_ai.system to 'openai' only when not already present

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
logfire/_internal/main.py Added override_provider parameter to instrument_openai() with documentation explaining its purpose and benefits
logfire/_internal/integrations/llm_providers/llm_provider.py Added override_provider parameter and logic to set gen_ai.system in span_data when provided
logfire/_internal/integrations/llm_providers/openai.py Modified on_response() to only set gen_ai.system='openai' when not already present, allowing override
tests/test_llm_provider.py Added parametrized unit tests for sync and async clients with override_provider
tests/otel_integrations/test_openai.py Added comprehensive integration tests covering sync, async, streaming, default behavior, and on_response behavior

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@alexmojaki
Copy link
Contributor

Please also set PROVIDER_NAME (gen_ai.provider.name) in addition to gen_ai.system, see the just-merged #1619

@yiphei
Copy link
Contributor Author

yiphei commented Jan 13, 2026

@alexmojaki done

provider = (getattr(span, 'attributes', {}) or {}).get('gen_ai.system', None)
if provider is None:
provider = 'openai'
span.set_attribute('gen_ai.system', provider)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also set PROVIDER_NAME

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexmojaki are you sure? PROVIDER_NAME is already set upstream in get_endpoint_config (e.g. https://github.com/pydantic/logfire/blob/main/logfire/_internal/integrations/llm_providers/openai.py#L98) so I dont think it makes sense to redo it

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, it needs to be configurable there too. gen_ai.system is deprecated and PROVIDER_NAME is replacing it. They should always be set together.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but it looks like gen_ai.system hasnt been deprecated yet and PROVIDER_NAME is still used upstream. Are you just trying to be forward-looking in this PR? If so, that would expand the scope of the PR to both a) support a new feature, and b) implement parts of a future deprecation. I generally dont like this expanding scope, but if thats what you are asking me, i will do it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to be clear on b), you are just asking me to implement:

correct?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

@yiphei yiphei Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexmojaki yes, but not deprecated in Logfire. Plus, I thought Logfire's whole selling point was its opinionated implementation of OTEL, so it doesnt seem like a necessity to be up to date with OTEL.

But besides all this, I simply objected to expanding this PR's scope. I would have been happy to open a separate PR to address this; just not in this PR.

Either way, I tried to please you and implemented what you asked, and it actually expanded the scope a lot more. See this draft PR: yiphei#7 . Given the expanded scope and further back and forth, I really dont want to do this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexmojaki if you agree to a stacked PR, im more amenable to that

return cast('ResponseT', response)

span.set_attribute('gen_ai.system', 'openai')
provider = (getattr(span, 'attributes', {}) or {}).get('gen_ai.system', None)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
provider = (getattr(span, 'attributes', {}) or {}).get('gen_ai.system', None)
provider = (getattr(span, 'attributes', {}) or {}).get(PROVIDER_NAME, None)

@yiphei yiphei requested a review from alexmojaki January 14, 2026 17:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants