Solved!
It turns out the issue was not with the oscillator itself, but with the default ramping behavior of the AUValue parameters in AudioKit/AudioKitEX.
When setting the frequency directly like this:
osc.frequency = AUValue(freq)
...AudioKit applies a small, non-zero ramp time by default to prevent clicks and pops, which resulted in the portamento effect.
The fix was to use the ramp(to:duration:) method and set the duration explicitly to 0.0:
osc.$frequency.ramp(to: AUValue(freq), duration: 0.0)